Merge branch 'feature/audit-executionid': ExecutionId universal audit correlation

Adds a dedicated ExecutionId column to the Audit Log — one universal per-run
correlation value stamped on every audit row (sync ApiCall/DbWrite, the cached
call lifecycle incl. the S&F retry-loop path, NotifySend, central NotifyDeliver,
inbound). CorrelationId keeps its per-operation lifecycle meaning. Additive
schema (AuditLog.ExecutionId, Notifications.OriginExecutionId); UI column +
filter + drill-in; CLI + ManagementService filter.
This commit is contained in:
Joseph Doherty
2026-05-21 16:30:41 -04:00
85 changed files with 6078 additions and 240 deletions

View File

@@ -132,6 +132,7 @@ This project contains design documentation for a distributed SCADA system built
- Layered design — append-only `AuditLog` (#23) sits alongside operational `Notifications` (#21) and `SiteCalls` (#22), not replacing them.
- Scope = script trust boundary: outbound API (sync + cached), outbound DB (sync + cached), notifications, inbound API. Framework/internal traffic is explicitly excluded.
- One row per lifecycle event; cached calls produce 4+ rows per operation (`Submitted`, `Forwarded`, `Attempted`, `Delivered`/`Parked`/`Discarded`).
- `ExecutionId` (`uniqueidentifier NULL`) is the universal per-run correlation value — every audit row emitted by one script execution / inbound request shares it; `CorrelationId` remains the per-operation lifecycle id (NULL for sync one-shots).
- Site SQLite hot-path first, then gRPC telemetry to central; ingest is idempotent on `EventId`; periodic reconciliation pull as fallback when telemetry is lost.
- Cached operations: site emits a single additively-extended `CachedCallTelemetry` packet carrying both audit events and operational state; central writes `AuditLog` + `SiteCalls` in one transaction.
- Payload cap 8 KB by default / 64 KB on error rows; auth headers redacted by default; SQL parameter values captured by default; per-target redaction opt-in.

View File

@@ -0,0 +1,115 @@
# Audit Log — ExecutionId Universal Correlation (Design)
**Date:** 2026-05-21
**Status:** Validated — ready for implementation planning.
## Problem
The audit `CorrelationId` column is overloaded with three incompatible meanings —
`TrackedOperationId` for cached calls, `NotificationId` for notifications, the
script-execution id for sync calls (added 2026-05-21), and request-local ids for
inbound. It is `NULL` for sync one-shot calls. There is no single value that ties
together *everything one script run (or inbound request) did*: a run that makes a
sync API call, a cached call and a notification produces three unrelated
correlation ids, and nothing links the cached call's lifecycle rows back to the
run that launched them.
A single `CorrelationId` column cannot serve both scopes — the **operation
lifecycle** (a cached call's `Submit→Attempted→Resolve`; a notification's
`Send→Deliver`, which the Site Calls / Notifications "View audit history"
drill-ins depend on) and the **execution trace** (all operations of one run).
## Decision
Add a dedicated, nullable **`ExecutionId`** column to the audit row. It identifies
the originating **script execution** or **inbound API request**. Every audit row
that execution produces carries the same `ExecutionId`. `CorrelationId` is left
exactly as it is — it keeps the per-operation lifecycle meaning, so the existing
operation drill-ins are unaffected.
Result: `WHERE ExecutionId = X` returns every audit row of one run — sync
`ApiCall`/`DbWrite`, the whole cached-call lifecycle, `NotifySend`,
`NotifyDeliver`, and the inbound row — across both the site and central tables.
`ScriptRuntimeContext` already holds a per-execution id (`_auditCorrelationId`,
added 2026-05-21). That id becomes the `ExecutionId`; this work stamps it into the
new column from every emitter and threads it to the two paths where the script
context is not in scope.
### Considered and rejected
- **Overload `CorrelationId`** with the execution id everywhere — breaks the
cached-call / notification "View audit history" drill-ins (they filter
`CorrelationId` by `TrackedOperationId` / `NotificationId`), or forces them to
show the whole run instead of the one operation.
- **Stash the execution id in `Extra` JSON** — no schema change, but `Extra` is
unindexed; filtering an audit table of this volume by it is unworkable.
## Schema changes (all additive, nullable — no backfill; pre-existing rows stay `NULL`)
| Where | Change |
|---|---|
| `ScadaLink.Commons` | `AuditEvent` record (and the site-local variant) gains `Guid? ExecutionId`. |
| Central MS SQL `AuditLog` | new `ExecutionId uniqueidentifier NULL` column + index `IX_AuditLog_Execution (ExecutionId)`. EF migration — additive nullable column is a metadata-only `ALTER`, fast even on the monthly-partitioned table. |
| Site SQLite `auditlog.db` `AuditLog` | new `ExecutionId TEXT NULL` column (`SqliteAuditWriter` schema + `MapRow`). |
| gRPC `AuditEventDto` (`sitestream.proto`) | additive `execution_id` field; `AuditEventDtoMapper` maps it both directions. |
| Central MS SQL `Notifications` | new `OriginExecutionId uniqueidentifier NULL` column — carries the originating run's id so the dispatcher can echo it onto `NotifyDeliver` audit rows. EF migration. |
`SiteCalls` needs no new column — the cached telemetry packet already carries the
audit half, which now has `ExecutionId` directly.
## Emitter coverage — every audit row carries `ExecutionId`
| Emitter | `ExecutionId` source |
|---|---|
| Sync `ApiCall`, sync `DbWrite` | `ScriptRuntimeContext` execution id (in scope today) |
| Cached call script-side rows (`CachedSubmit`, immediate `Attempted`/`CachedResolve`) | `ScriptRuntimeContext` execution id |
| Cached call **S&F retry-loop** rows (`CachedCallLifecycleBridge`) | threaded through the store-and-forward buffered message → `CachedCallAttemptContext` → the bridge. This same threading also fixes the pre-existing `SourceScript = NULL` gap on those rows (identical boundary). |
| `NotifySend` (site, script-side) | `ScriptRuntimeContext` execution id |
| `NotifyDeliver` (central dispatch) | `Notifications.OriginExecutionId` — the id rides on `NotificationSubmit`, is persisted on the `Notifications` row, and the dispatcher stamps it on every `NotifyDeliver` row |
| Inbound `InboundRequest` / `InboundAuthFailure` | request id minted once in `AuditWriteMiddleware` |
## Data flow
- **Site script run** — `ScriptRuntimeContext` generates the execution id (or is
given one); every emitter it owns stamps `ExecutionId`.
- **Buffered cached call** — the execution id rides on the S&F buffered message;
the retry loop reconstructs it into `CachedCallAttemptContext`;
`CachedCallLifecycleBridge` stamps it on the retry-loop audit rows.
- **Notification** — the `NotifySend` row stamps it site-side; the id travels on
`NotificationSubmit`, is stored as `Notifications.OriginExecutionId`, and the
dispatcher stamps every `NotifyDeliver` row it emits.
- **Inbound API request** — `AuditWriteMiddleware` mints a request id and stamps
the inbound audit row.
## UI / CLI surface
- **Central UI Audit Log page** — `ExecutionId` added as a results-grid column
(the grid already supports resize/reorder); an `ExecutionId` paste-filter in
the filter bar; the page accepts `?executionId=<guid>`; a row drill-in
"View this execution" → `/audit/log?executionId=<guid>`.
- **CLI** — `scadalink audit query --execution-id <guid>`.
- **ManagementService** — `/api/audit/query` and the export endpoint accept an
`executionId` filter parameter.
## Compatibility
- Two additive nullable columns; additive proto field; additive message-contract
fields — all version-compatible. No data backfill; historical rows keep
`ExecutionId = NULL`.
- `CorrelationId` semantics unchanged — every existing drill-in keeps working.
## Testing
- Repository: query-by-`ExecutionId`; migration smoke test.
- Emitter unit tests: each emitter stamps `ExecutionId`; the cached-call lifecycle
rows from one run share it; `NotifyDeliver` echoes `Notifications.OriginExecutionId`.
- Integration: a script run that does a sync call + a cached call + a notification
→ all resulting audit rows share one `ExecutionId` end-to-end.
- Central UI: bUnit (grid column, filter, drill-in) + Playwright.
## Out of scope
- Bridging the inbound request id into the routed site script's execution
(cross-cluster threading) — a separate future change.
- Backfilling `ExecutionId` on historical audit rows.

View File

@@ -0,0 +1,155 @@
# Audit Log ExecutionId — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to execute this plan task-by-task (fresh implementer per task + spec review + code-quality review).
**Goal:** Add a dedicated `ExecutionId` column to the Audit Log — one universal correlation value, stamped on every audit row, identifying the originating script execution or inbound request.
**Architecture:** Additive nullable `ExecutionId` (`Guid`) on the audit row (Commons `AuditEvent`, central MS SQL `AuditLog`, site SQLite `auditlog.db`, gRPC `AuditEventDto`). Every emitter stamps it; the `ScriptRuntimeContext` per-execution id is the source for site script runs, threaded through the S&F buffer for retry-loop cached rows and through `NotificationSubmit``Notifications.OriginExecutionId` for central `NotifyDeliver` rows. `CorrelationId` is left as the per-operation lifecycle id (and reverts to `null` for sync one-shot calls). Validated design: `docs/plans/2026-05-21-audit-executionid-design.md`.
**Tech Stack:** .NET 10, EF Core 10 (MS SQL + SQLite), Akka.NET, gRPC, Blazor Server + Bootstrap, System.CommandLine, xUnit + Akka.TestKit.Xunit2 + bUnit + NSubstitute/Moq, Playwright.
**Ground rules (every task):** branch is `feature/audit-executionid` (already created) — never commit to `main`. Edit in place; never touch `infra/*`; `docker/*` only if a task says so (none do). Stage with explicit `git add <path>` — never `git add .` / `commit -am`. TDD; full solution stays green (`dotnet build ScadaLink.slnx` 0 warnings — `TreatWarningsAsErrors` is on). Additive contract evolution. Do not push.
---
## Task 0: Prep — verify branch + baseline
**Files:** none.
**Steps:** confirm `git branch --show-current` is `feature/audit-executionid`; `dotnet build ScadaLink.slnx` succeeds.
**Acceptance:** on the branch, solution builds clean.
---
## Task 1: Foundation — `AuditEvent.ExecutionId`, central `AuditLog` column, repository query
**Files:**
- Modify: `src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs` — add `Guid? ExecutionId`.
- Modify: `src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs` — add `Guid? ExecutionId` filter dimension (single-value, like `CorrelationId`).
- Modify: `src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs` — map the column; add index `IX_AuditLog_Execution (ExecutionId)`.
- Create: a new EF migration under `src/ScadaLink.ConfigurationDatabase/Migrations/``AddAuditLogExecutionId``ExecutionId uniqueidentifier NULL` + the index. Additive nullable column (metadata-only ALTER, safe on the monthly-partitioned table). Mirror the existing `AddNotificationsTable` migration style.
- Modify: `src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs``QueryAsync` translates `filter.ExecutionId` to `e.ExecutionId == value` (mirror the `CorrelationId` clause). Keyset paging untouched.
- Test: `tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs``QueryAsync_FilterByExecutionId`; migration smoke if the suite has that pattern.
**Approach:** purely additive. `ExecutionId` is `Guid?` everywhere. Generate the migration with `dotnet ef migrations add` against the ConfigurationDatabase project (or hand-write mirroring an existing one — match how the repo does migrations).
**Commit:** `feat(auditlog): ExecutionId column on AuditEvent + central AuditLog`
---
## Task 2: Foundation — site SQLite + gRPC DTO
**Files:**
- Modify: `src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs` — add `ExecutionId TEXT NULL` to the `auditlog.db` `AuditLog` table DDL; the insert command binds it; `MapRow` reads it back. (Site SQLite is created fresh by the writer — an additive column in the `CREATE TABLE` is enough; if the writer has any migration/ALTER path, extend it.)
- Modify: `src/ScadaLink.Communication/Protos/sitestream.proto` — add `string execution_id` to `AuditEventDto` (next free field number; additive). Rebuild regenerates the C# stubs.
- Modify: `src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs``ToDto`/`FromDto` map `ExecutionId``execution_id` (Guid ↔ string; empty string ↔ null, mirroring the existing `CorrelationId` handling).
- Test: `tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs` (column present + round-trips); `tests/ScadaLink.Communication.Tests/AuditEventDtoMapperTests.cs` (ExecutionId round-trip incl. null).
**Commit:** `feat(auditlog): ExecutionId on site SQLite schema + gRPC AuditEventDto`
---
## Task 3: Site script-side emitters stamp `ExecutionId`
**What:** Every audit row a `ScriptRuntimeContext` emits gets `ExecutionId` = the context's per-execution id. Revert the interim "execution id in `CorrelationId` for sync rows" change so `CorrelationId` is purely per-operation again.
**Files:**
- Modify: `src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs`:
- Rename the field `_auditCorrelationId``_executionId` (and the ctor param `auditCorrelationId``executionId`) for clarity; update XML docs. Thread it to the helpers as today.
- Sync `ApiCall` (`BuildCallAuditEvent`): set `ExecutionId = _executionId`; set `CorrelationId = null` (revert — sync one-shot calls have no operation lifecycle).
- Cached script-side rows (`CachedSubmit`, immediate `ApiCallCached`/`CachedResolve`): set `ExecutionId = _executionId`; `CorrelationId` stays `trackedId.Value`.
- `NotifySend` (`Notify.Send` emission): set `ExecutionId = _executionId`; `CorrelationId` stays the `NotificationId`.
- Modify: `src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs` + `AuditingDbCommand.cs` — thread `_executionId` (rename from the audit-correlation param); sync `DbWrite` event sets `ExecutionId = _executionId` and `CorrelationId = null`. Cached DB write rows: `ExecutionId` set, `CorrelationId` stays `trackedId`.
- Test: extend `tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs`, `DatabaseSyncEmissionTests.cs`, `ExternalSystemCachedCallEmissionTests.cs`, `DatabaseCachedWriteEmissionTests.cs`, `NotifySendAuditEmissionTests.cs`, and `ExecutionCorrelationContextTests.cs` — assert `ExecutionId` is the context's id on every row; assert sync rows now have `CorrelationId == null`; assert cached/notification rows keep their `CorrelationId`.
**Commit:** `feat(auditlog): site script-side emitters stamp ExecutionId`
---
## Task 4: Cached S&F retry-loop rows carry `ExecutionId`
**What:** Thread the execution id through the store-and-forward buffer so the retry-loop cached audit rows (`CachedCallLifecycleBridge`) carry `ExecutionId`. This same threading fixes the pre-existing `SourceScript = null` gap on those rows (identical boundary).
**Files:**
- Modify: the S&F buffered cached-call message / `StoreAndForwardMessage` (or the cached-call payload) in `src/ScadaLink.StoreAndForward/` — carry the originating execution id (and source script) alongside the call.
- Modify: `CachedCallAttemptContext` (find it — `src/ScadaLink.AuditLog/Site/Telemetry/` or StoreAndForward) — add an `ExecutionId` (and `SourceScript`) field.
- Modify: `src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs` `BuildPacket` — set `ExecutionId` from the context (and `SourceScript`, replacing the `SourceScript = null` line).
- Modify the enqueue path (`ExternalSystem.CachedCall` / `Database.CachedWrite` in `ScriptRuntimeContext`) so the execution id is written into the buffered message.
- Test: `tests/ScadaLink.AuditLog.Tests/` cached-telemetry tests + `tests/ScadaLink.StoreAndForward.Tests/` — retry-loop rows carry the originating `ExecutionId`.
**Note for implementer:** this is the deepest task — the threading touches StoreAndForward. If the buffered message can't cleanly carry the id, STOP and report before guessing.
**Commit:** `feat(auditlog): thread ExecutionId through S&F for retry-loop cached rows`
---
## Task 5: Central `NotifyDeliver` rows carry `ExecutionId`
**Files:**
- Modify: `src/ScadaLink.Commons/Entities/Notifications/Notification.cs` — add `Guid? OriginExecutionId`.
- Modify: `src/ScadaLink.Commons/Messages/Notification/``NotificationSubmit` carries `Guid? OriginExecutionId` (additive).
- Modify: `src/ScadaLink.ConfigurationDatabase/` — EF config + a new migration `AddNotificationOriginExecutionId` (`Notifications.OriginExecutionId uniqueidentifier NULL`).
- Modify: the site `NotifySend` forward path — the execution id (already on the `NotifySend` audit row from Task 3) also rides on the `NotificationSubmit` (set it where the submit is built — `ScriptRuntimeContext` `Notify.Send` / the S&F notification forwarder).
- Modify: `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs` — persist `OriginExecutionId` on insert; `BuildNotifyDeliverEvent` sets `ExecutionId = notification.OriginExecutionId`.
- Test: `tests/ScadaLink.NotificationOutbox.Tests/``NotifyDeliver` rows echo `OriginExecutionId`; `tests/ScadaLink.Commons.Tests/` contract shape.
**Commit:** `feat(auditlog): NotifyDeliver rows carry the originating ExecutionId`
---
## Task 6: Inbound rows carry `ExecutionId`
**Files:**
- Modify: `src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs``EmitInboundAudit` sets `ExecutionId` to the request id (it already mints a `Guid.NewGuid()` for the inbound `CorrelationId` per the 2026-05-21 change; reuse that one id for `ExecutionId` — and reconsider whether the inbound row's `CorrelationId` should now be `null` to keep `CorrelationId` purely per-operation; align with the Task 3 decision: inbound is a one-shot from the audit row's perspective → `CorrelationId = null`, `ExecutionId = <request id>`).
- Test: `tests/ScadaLink.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs` — inbound row carries a non-null `ExecutionId`; distinct per request.
**Commit:** `feat(auditlog): inbound audit rows carry ExecutionId`
---
## Task 7: Central UI — ExecutionId column, filter, drill-in
**Files:**
- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor` (+ `.razor.cs`) — add `ExecutionId` to the column set (the grid already supports resize/reorder + a `ColumnOrder`); render it (short form / monospace).
- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor` (+ `.razor.cs`) + `AuditQueryModel.cs` — an `ExecutionId` paste text-filter; `ToFilter` maps it to `AuditLogQueryFilter.ExecutionId`.
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs``ApplyQueryStringFilters` accepts `?executionId=<guid>`; `BuildExportUrl` emits it.
- Add a "View this execution" drill-in — a row/drilldown action linking `/audit/log?executionId=<guid>`. Mirror the existing `?correlationId=` drill-in.
- Test: `tests/ScadaLink.CentralUI.Tests/` bUnit (column renders, filter maps, query-param parsed); `tests/ScadaLink.CentralUI.PlaywrightTests/Audit/` (drill-in filters the grid).
Use the `frontend-design` skill for the column/filter styling.
**Commit:** `feat(centralui): ExecutionId column, filter and drill-in on the Audit Log page`
---
## Task 8: CLI + ManagementService — ExecutionId filter
**Files:**
- Modify: `src/ScadaLink.CLI/Commands/AuditCommands.cs` + `AuditQueryHelpers.cs``audit query --execution-id <guid>`; `AuditQueryArgs` + `BuildQueryString` emit `executionId`.
- Modify: `src/ScadaLink.ManagementService/AuditEndpoints.cs` `ParseFilter` — parse `executionId` query param into `AuditLogQueryFilter.ExecutionId` (lax-parse — unparseable dropped).
- Modify: `src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs` `ParseFilter` — same.
- Test: `tests/ScadaLink.CLI.Tests/`, `tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs`.
**Commit:** `feat(audit): ExecutionId filter in the CLI and ManagementService`
---
## Task 9: End-to-end integration test + docs
**Files:**
- Create: `tests/ScadaLink.IntegrationTests/AuditLog/ExecutionIdCorrelationTests.cs` — boot a site+central pair; run a script that does a sync `ExternalSystem.Call`, a cached call, and a `Notify.Send`; assert every resulting audit row (site + central) shares one `ExecutionId`.
- Modify: `docs/requirements/Component-AuditLog.md` — add `ExecutionId` to the schema table and a sentence on its meaning vs `CorrelationId`. (Do NOT modify `alog.md` — it is the locked v1 spec.)
- Modify: `CLAUDE.md` — one line under the Centralized Audit Log decisions noting `ExecutionId` as the universal per-run correlation value.
**Commit:** `test(auditlog): end-to-end ExecutionId correlation + docs`
---
## Final review
Dispatch a final cross-cutting review of the whole branch; full `dotnet build` + `dotnet test ScadaLink.slnx`; hand back to the user for the push/merge/redeploy decision (do not push).
## Dependency summary
0 blocks all. 2 blockedBy 1. 3 blockedBy 2. 4 blockedBy 3. 5 blockedBy 2. 6 blockedBy 2. 7 blockedBy 1. 8 blockedBy 1. 9 blockedBy 3,4,5,6,7,8. Execution order: 0 → 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → final review.

View File

@@ -0,0 +1,16 @@
{
"planPath": "docs/plans/2026-05-21-audit-executionid.md",
"tasks": [
{"id": 50, "subject": "Task 0: Prep — verify branch + baseline", "status": "pending"},
{"id": 51, "subject": "Task 1: Foundation — AuditEvent.ExecutionId + central AuditLog column + repo query", "status": "pending", "blockedBy": [50]},
{"id": 52, "subject": "Task 2: Foundation — site SQLite + gRPC DTO", "status": "pending", "blockedBy": [51]},
{"id": 53, "subject": "Task 3: Site script-side emitters stamp ExecutionId", "status": "pending", "blockedBy": [52]},
{"id": 54, "subject": "Task 4: Cached S&F retry-loop rows carry ExecutionId", "status": "pending", "blockedBy": [53]},
{"id": 55, "subject": "Task 5: Central NotifyDeliver rows carry ExecutionId", "status": "pending", "blockedBy": [52]},
{"id": 56, "subject": "Task 6: Inbound audit rows carry ExecutionId", "status": "pending", "blockedBy": [52]},
{"id": 57, "subject": "Task 7: Central UI — ExecutionId column, filter, drill-in", "status": "pending", "blockedBy": [51]},
{"id": 58, "subject": "Task 8: CLI + ManagementService — ExecutionId filter", "status": "pending", "blockedBy": [51]},
{"id": 59, "subject": "Task 9: End-to-end integration test + docs", "status": "pending", "blockedBy": [53, 54, 55, 56, 57, 58]}
],
"lastUpdated": "2026-05-21T00:00:00Z"
}

View File

@@ -83,6 +83,7 @@ row per lifecycle event across all channels.
| `Channel` | `varchar(32)` | `ApiOutbound` \| `DbOutbound` \| `Notification` \| `ApiInbound`. |
| `Kind` | `varchar(32)` | Event kind discriminator (see kinds list below). |
| `CorrelationId` | `uniqueidentifier` NULL | Ties multi-event operations together. `TrackedOperationId` for cached calls, `NotificationId` for notifications, request-id for inbound API. NULL for sync one-shot calls. |
| `ExecutionId` | `uniqueidentifier` NULL | The originating script execution / inbound request — the universal per-run correlation value; distinct from `CorrelationId`, which is the per-operation lifecycle id. Stamped on *every* audit row emitted by one execution. |
| `SourceSiteId` | `varchar(64)` NULL | NULL for central-originated events. |
| `SourceInstanceId` | `varchar(128)` NULL | Instance whose script initiated the action (when applicable). |
| `SourceScript` | `varchar(128)` NULL | Script name within the instance. |
@@ -102,7 +103,8 @@ row per lifecycle event across all channels.
- `IX_AuditLog_OccurredAtUtc` — primary time-range index for global scans.
- `IX_AuditLog_Site_Occurred (SourceSiteId, OccurredAtUtc)` — per-site filters.
- `IX_AuditLog_Correlation (CorrelationId)` — drilldown from a single operation.
- `IX_AuditLog_CorrelationId (CorrelationId)` — drilldown from a single operation.
- `IX_AuditLog_Execution (ExecutionId)` — drilldown to every action of one script execution / inbound request.
- `IX_AuditLog_Channel_Status_Occurred (Channel, Status, OccurredAtUtc)` — KPI / dashboard tiles.
- `IX_AuditLog_Target_Occurred (Target, OccurredAtUtc)` — "what did we send to system X".
- Monthly partitioning on `OccurredAtUtc` from day one; purge is a partition switch (see Retention & Purge).
@@ -126,6 +128,27 @@ Inbound API is intentionally collapsed to a single `InboundRequest` (or
`InboundAuthFailure` for auth rejections) row per request rather than a
multi-event lifecycle.
### `ExecutionId` vs `CorrelationId`
The table carries two correlation columns at different granularities:
- **`ExecutionId`** is the *universal per-run* value: one id per script
execution (tag-change / timer-triggered or otherwise) or per inbound API
request. It is stamped on **every** audit row that run produces — the sync
`ApiCall` and `DbWrite` rows, the full cached-call lifecycle, the
`NotifySend` / `NotifyDeliver` rows, and the inbound row alike. A run that
performs no trust-boundary action emits no rows, but any run that emits
multiple rows ties them all together under one `ExecutionId`. This lets an
audit reader pull the complete trust-boundary footprint of a single script
run with one `ExecutionId` filter.
- **`CorrelationId`** is the *per-operation lifecycle* id — it groups the
multiple events of one long-running operation (`TrackedOperationId` for a
cached call, `NotificationId` for a notification, request-id for inbound
API) and is NULL for sync one-shot calls that have no operation lifecycle.
The two are orthogonal: one execution may touch several operations (each with
its own `CorrelationId`) yet every resulting row shares the one `ExecutionId`.
## The Site-Local `AuditLog` (SQLite)
A SQLite database file on each site node, alongside the Store-and-Forward

View File

@@ -114,12 +114,53 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
PayloadTruncated INTEGER NOT NULL,
Extra TEXT NULL,
ForwardState TEXT NOT NULL,
ExecutionId TEXT NULL,
PRIMARY KEY (EventId)
);
CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred
ON AuditLog (ForwardState, OccurredAtUtc);
""";
cmd.ExecuteNonQuery();
// Audit Log #23 (ExecutionId): additively add the ExecutionId column.
// CREATE TABLE IF NOT EXISTS above does NOT add columns to an AuditLog
// table that already exists from a pre-ExecutionId build, so an
// auditlog.db created by an older build needs the column ALTER-ed in.
// The file is durable across restart/failover by design (7-day
// retention), so without this step every WriteAsync on an upgraded
// deployment would bind $ExecutionId against a missing column and the
// best-effort write path would silently drop every site audit row.
// SQLite has no "ADD COLUMN IF NOT EXISTS"; the column presence is
// probed first and the ALTER skipped when already there. The column is
// nullable with no default, so any row written before this migration
// reads back ExecutionId = null (back-compat).
AddColumnIfMissing("ExecutionId", "TEXT NULL");
}
/// <summary>
/// Audit Log #23 (ExecutionId): adds a column to <c>AuditLog</c> only when
/// it is not already present. SQLite lacks <c>ADD COLUMN IF NOT EXISTS</c>,
/// so the schema is probed via <c>PRAGMA table_info</c> first. Idempotent —
/// safe to run on every <see cref="InitializeSchema"/>. Mirrors
/// <c>StoreAndForwardStorage.AddColumnIfMissingAsync</c>; kept synchronous
/// here to match the rest of this writer's bootstrap DDL.
/// </summary>
private void AddColumnIfMissing(string columnName, string columnDefinition)
{
using var probe = _connection.CreateCommand();
probe.CommandText = "SELECT COUNT(*) FROM pragma_table_info('AuditLog') WHERE name = $name";
probe.Parameters.AddWithValue("$name", columnName);
var exists = Convert.ToInt32(probe.ExecuteScalar()) > 0;
if (exists)
{
return;
}
using var alter = _connection.CreateCommand();
// Column name + definition are caller-controlled constants, never user
// input — safe to interpolate (parameters are not permitted in DDL).
alter.CommandText = $"ALTER TABLE AuditLog ADD COLUMN {columnName} {columnDefinition}";
alter.ExecuteNonQuery();
}
/// <summary>
@@ -221,12 +262,14 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
ExecutionId
) VALUES (
$EventId, $OccurredAtUtc, $Channel, $Kind, $CorrelationId,
$SourceSiteId, $SourceInstanceId, $SourceScript, $Actor, $Target,
$Status, $HttpStatus, $DurationMs, $ErrorMessage, $ErrorDetail,
$RequestSummary, $ResponseSummary, $PayloadTruncated, $Extra, $ForwardState
$RequestSummary, $ResponseSummary, $PayloadTruncated, $Extra, $ForwardState,
$ExecutionId
);
""";
@@ -250,6 +293,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
var pPayloadTruncated = cmd.Parameters.Add("$PayloadTruncated", SqliteType.Integer);
var pExtra = cmd.Parameters.Add("$Extra", SqliteType.Text);
var pForwardState = cmd.Parameters.Add("$ForwardState", SqliteType.Text);
var pExecutionId = cmd.Parameters.Add("$ExecutionId", SqliteType.Text);
foreach (var pending in batch)
{
@@ -274,6 +318,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
pPayloadTruncated.Value = e.PayloadTruncated ? 1 : 0;
pExtra.Value = (object?)e.Extra ?? DBNull.Value;
pForwardState.Value = (e.ForwardState ?? AuditForwardState.Pending).ToString();
pExecutionId.Value = (object?)e.ExecutionId?.ToString() ?? DBNull.Value;
try
{
@@ -331,7 +376,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
ExecutionId
FROM AuditLog
WHERE ForwardState = $pending
ORDER BY OccurredAtUtc ASC, EventId ASC
@@ -379,7 +425,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
ExecutionId
FROM AuditLog
WHERE ForwardState = $forwarded
ORDER BY OccurredAtUtc ASC, EventId ASC
@@ -465,7 +512,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
ExecutionId
FROM AuditLog
WHERE ForwardState IN ($pending, $forwarded)
AND OccurredAtUtc >= $since
@@ -642,6 +690,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
PayloadTruncated = reader.GetInt32(17) != 0,
Extra = reader.IsDBNull(18) ? null : reader.GetString(18),
ForwardState = Enum.Parse<AuditForwardState>(reader.GetString(19)),
ExecutionId = reader.IsDBNull(20) ? null : Guid.Parse(reader.GetString(20)),
};
}

View File

@@ -133,9 +133,17 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver
Channel = channel,
Kind = kind,
CorrelationId = context.TrackedOperationId.Value,
// Audit Log #23 (ExecutionId Task 4): the originating script
// execution's per-run correlation id, threaded through the S&F
// buffer; null on rows buffered before Task 4 (back-compat).
ExecutionId = context.ExecutionId,
SourceSiteId = string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite,
SourceInstanceId = context.SourceInstanceId,
SourceScript = null, // Not threaded through S&F; left null on retry-loop rows.
// Audit Log #23 (ExecutionId Task 4): SourceScript is now
// threaded through the S&F buffer alongside ExecutionId — the
// retry-loop cached rows carry the same provenance the
// script-side cached rows do. Null on pre-Task-4 buffered rows.
SourceScript = context.SourceScript,
Target = context.Target,
Status = status,
HttpStatus = httpStatus,

View File

@@ -59,6 +59,7 @@ public static class AuditCommands
var targetOption = new Option<string?>("--target") { Description = "Filter by target (external system, DB connection, notification list)" };
var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" };
var correlationIdOption = new Option<string?>("--correlation-id") { Description = "Filter by correlation ID" };
var executionIdOption = new Option<string?>("--execution-id") { Description = "Filter by execution ID" };
var errorsOnlyOption = new Option<bool>("--errors-only") { Description = "Show only failed events (status=Failed; overrides --status)" };
var pageSizeOption = new Option<int>("--page-size") { Description = "Events per page (1-1000)" };
pageSizeOption.DefaultValueFactory = _ => 100;
@@ -74,6 +75,7 @@ public static class AuditCommands
cmd.Add(targetOption);
cmd.Add(actorOption);
cmd.Add(correlationIdOption);
cmd.Add(executionIdOption);
cmd.Add(errorsOnlyOption);
cmd.Add(pageSizeOption);
cmd.Add(allOption);
@@ -101,6 +103,7 @@ public static class AuditCommands
Target = result.GetValue(targetOption),
Actor = result.GetValue(actorOption),
CorrelationId = result.GetValue(correlationIdOption),
ExecutionId = result.GetValue(executionIdOption),
ErrorsOnly = result.GetValue(errorsOnlyOption),
PageSize = result.GetValue(pageSizeOption),
};

View File

@@ -24,6 +24,7 @@ public sealed class AuditQueryArgs
public string? Target { get; set; }
public string? Actor { get; set; }
public string? CorrelationId { get; set; }
public string? ExecutionId { get; set; }
public bool ErrorsOnly { get; set; }
public int PageSize { get; set; } = 100;
}
@@ -125,6 +126,7 @@ public static class AuditQueryHelpers
Add("target", args.Target);
Add("actor", args.Actor);
Add("correlationId", args.CorrelationId);
Add("executionId", args.ExecutionId);
Add("pageSize", args.PageSize.ToString(CultureInfo.InvariantCulture));
if (afterOccurredAtUtc.HasValue)

View File

@@ -105,6 +105,13 @@ public static class AuditExportEndpoints
correlationId = parsedCorr;
}
Guid? executionId = null;
if (query.TryGetValue("executionId", out var execValues)
&& Guid.TryParse(execValues.ToString(), out var parsedExec))
{
executionId = parsedExec;
}
DateTime? fromUtc = ParseUtcDate(query, "from");
DateTime? toUtc = ParseUtcDate(query, "to");
@@ -116,6 +123,7 @@ public static class AuditExportEndpoints
Target: target,
Actor: actor,
CorrelationId: correlationId,
ExecutionId: executionId,
FromUtc: fromUtc,
ToUtc: toUtc);
}

View File

@@ -55,6 +55,9 @@
<dt class="col-4 text-muted fw-normal">CorrelationId</dt>
<dd class="col-8 font-monospace" data-test="field-CorrelationId">@(Event.CorrelationId?.ToString() ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">ExecutionId</dt>
<dd class="col-8 font-monospace" data-test="field-ExecutionId">@(Event.ExecutionId?.ToString() ?? "—")</dd>
<dt class="col-4 text-muted fw-normal">OccurredAtUtc</dt>
<dd class="col-8 font-monospace" data-test="field-OccurredAtUtc">@FormatTimestamp(Event.OccurredAtUtc)</dd>
@@ -151,6 +154,14 @@
Show all events for this operation
</button>
}
@if (Event.ExecutionId is not null)
{
<button class="btn btn-outline-secondary btn-sm"
data-test="view-this-execution"
@onclick="ViewThisExecution">
View this execution
</button>
}
<button class="btn btn-primary btn-sm ms-auto"
data-test="drawer-close-footer"
@onclick="HandleClose">

View File

@@ -47,9 +47,10 @@ namespace ScadaLink.CentralUI.Components.Audit;
/// <para>
/// <b>Drill-back.</b> When <see cref="AuditEvent.CorrelationId"/> is set,
/// the "Show all events" button navigates to
/// <c>/audit/log?correlationId={id}</c>. The parent page does not
/// auto-apply that filter today — it is a deep link the page can use
/// when Bundle D wires up query-string deserialization.
/// <c>/audit/log?correlationId={id}</c>. Likewise, when
/// <see cref="AuditEvent.ExecutionId"/> is set the "View this execution"
/// button navigates to <c>/audit/log?executionId={id}</c>. Both are deep
/// links the Audit Log page deserializes on init (Bundle D) and auto-loads.
/// </para>
/// </summary>
public partial class AuditDrilldownDrawer
@@ -276,6 +277,20 @@ public partial class AuditDrilldownDrawer
Navigation.NavigateTo(uri);
}
/// <summary>
/// Drill-in to every audit row sharing this row's <see cref="AuditEvent.ExecutionId"/>
/// — the universal per-run correlation value, distinct from the per-operation
/// CorrelationId drill-back above. Navigates to <c>/audit/log?executionId={id}</c>,
/// which the page parses on init and auto-loads. The button is only rendered
/// when <see cref="AuditEvent.ExecutionId"/> is non-null, so this is total.
/// </summary>
private void ViewThisExecution()
{
if (Event?.ExecutionId is not { } exec) return;
var uri = $"/audit/log?executionId={exec}";
Navigation.NavigateTo(uri);
}
/// <summary>
/// Build a cURL command from an audit event. The URL comes from
/// <c>Target</c>; when the RequestSummary parses as

View File

@@ -117,6 +117,16 @@
placeholder="contains…" @bind="_model.ActorSearch" />
</div>
@* ExecutionId is an exact-match Guid filter — the operator pastes the
universal per-run correlation value. Lax-parsed in ToFilter so a
blank/malformed paste simply drops the constraint. *@
<div class="col-auto" data-test="filter-execution-id">
<label class="form-label small mb-1" for="audit-execution-id">Execution ID</label>
<input id="audit-execution-id" type="text"
class="form-control form-control-sm font-monospace"
placeholder="paste GUID…" @bind="_model.ExecutionId" />
</div>
<div class="col-auto" data-test="filter-errors-only">
<div class="form-check mb-1">
<input class="form-check-input" type="checkbox" id="audit-errors-only"

View File

@@ -135,6 +135,7 @@ public partial class AuditFilterBar
_model.ScriptSearch = string.Empty;
_model.TargetSearch = string.Empty;
_model.ActorSearch = string.Empty;
_model.ExecutionId = string.Empty;
_model.ErrorsOnly = false;
}

View File

@@ -47,6 +47,14 @@ public sealed class AuditQueryModel
public string TargetSearch { get; set; } = string.Empty;
public string ActorSearch { get; set; } = string.Empty;
/// <summary>
/// Paste-in ExecutionId filter — the operator pastes the universal per-run
/// correlation Guid. Stored as free text; <see cref="ToFilter"/> lax-parses it
/// through <see cref="Guid.TryParse(string?, out Guid)"/> so a blank or
/// unparseable value simply yields no constraint.
/// </summary>
public string ExecutionId { get; set; } = string.Empty;
public bool ErrorsOnly { get; set; }
/// <summary>
@@ -114,6 +122,12 @@ public sealed class AuditQueryModel
var (fromUtc, toUtc) = ResolveTimeWindow(utcNow);
// Lax-parse the pasted ExecutionId — blank or malformed text yields no
// constraint rather than an error, mirroring the optional-filter contract.
Guid? executionId = Guid.TryParse(ExecutionId, out var parsedExecutionId)
? parsedExecutionId
: null;
return new AuditLogQueryFilter(
Channels: Channels.Count > 0 ? Channels.ToArray() : null,
Kinds: Kinds.Count > 0 ? Kinds.ToArray() : null,
@@ -122,6 +136,7 @@ public sealed class AuditQueryModel
Target: string.IsNullOrWhiteSpace(TargetSearch) ? null : TargetSearch.Trim(),
Actor: string.IsNullOrWhiteSpace(ActorSearch) ? null : ActorSearch.Trim(),
CorrelationId: null,
ExecutionId: executionId,
FromUtc: fromUtc,
ToUtc: toUtc);
}

View File

@@ -83,6 +83,15 @@
</div>
@code {
// Compact display for Guid id columns: the first 8 hex digits, mirroring
// the drilldown drawer's ShortEventId presentation. The full value is kept
// in the cell's title attribute so it stays copy-paste accessible.
private static string ShortGuid(Guid value)
{
var n = value.ToString("N");
return n.Length >= 8 ? n[..8] : n;
}
private RenderFragment RenderCell(string key, AuditEvent row) => __builder =>
{
switch (key)
@@ -111,6 +120,18 @@
case "Actor":
<span class="small">@(row.Actor ?? "—")</span>
break;
case "ExecutionId":
@if (row.ExecutionId is { } executionId)
{
<span class="small font-monospace"
data-test="execution-id-@row.EventId"
title="@executionId">@ShortGuid(executionId)</span>
}
else
{
<span class="small text-muted">—</span>
}
break;
case "DurationMs":
<span class="small font-monospace">@(row.DurationMs?.ToString() ?? "—")</span>
break;

View File

@@ -9,9 +9,10 @@ namespace ScadaLink.CentralUI.Components.Audit;
/// <summary>
/// Keyset-paged results grid for the central Audit Log page (#23 M7-T3).
/// Renders the 10 columns named in Component-AuditLog.md §10:
/// OccurredAtUtc, Site, Channel, Kind, Status, Target, Actor, DurationMs,
/// HttpStatus, ErrorMessage. Talks to <see cref="Services.IAuditLogQueryService"/>
/// Renders the columns named in Component-AuditLog.md §10 — OccurredAtUtc,
/// Site, Channel, Kind, Status, Target, Actor, DurationMs, HttpStatus,
/// ErrorMessage — plus the ExecutionId per-run correlation column. Talks to
/// <see cref="Services.IAuditLogQueryService"/>
/// — never to <c>IAuditLogRepository</c> directly — so tests can stub the data
/// source without standing up EF Core.
///
@@ -121,6 +122,7 @@ public partial class AuditResultsGrid : IAsyncDisposable
("Status", "Status"),
("Target", "Target"),
("Actor", "Actor"),
("ExecutionId", "ExecutionId"),
("DurationMs", "DurationMs"),
("HttpStatus", "HttpStatus"),
("ErrorMessage", "ErrorMessage"),

View File

@@ -22,7 +22,8 @@ namespace ScadaLink.CentralUI.Components.Pages.Audit;
/// <c>?actor=</c>, <c>?site=</c>, <c>?channel=</c>, <c>?kind=</c>, and the UI-only
/// <c>?instance=</c> are read on initialization. Bundle E (M7-T13) extends
/// this with <c>?status=</c> so the Health-dashboard Audit error-rate tile can
/// drill in to <c>?status=Failed</c>. When any param is present we allocate a
/// drill in to <c>?status=Failed</c>. The ExecutionId follow-up adds
/// <c>?executionId=</c> for the "View this execution" drill-in. When any param is present we allocate a
/// fresh <see cref="AuditLogQueryFilter"/> and assign it to
/// <see cref="_currentFilter"/>, which kicks the results grid into auto-load
/// without the user clicking Apply. Unknown values (e.g. an invalid enum name)
@@ -60,6 +61,16 @@ public partial class AuditLogPage
correlationId = parsedCorr;
}
// ?executionId= is the "View this execution" drill-in target — the
// universal per-run correlation value. Lax-parsed like ?correlationId=:
// an unparseable value is silently dropped (no constraint).
Guid? executionId = null;
if (query.TryGetValue("executionId", out var execValues)
&& Guid.TryParse(execValues.ToString(), out var parsedExec))
{
executionId = parsedExec;
}
string? target = null;
if (query.TryGetValue("target", out var targetValues))
{
@@ -117,7 +128,7 @@ public partial class AuditLogPage
// auto-loads. Pure ?instance= deep links (UI-only) do not trigger auto-load
// because the filter contract has no instance column — the user still needs
// to refine + Apply for those.
if (correlationId is null && target is null && actor is null
if (correlationId is null && executionId is null && target is null && actor is null
&& sites is null && channels is null && kinds is null && statuses is null)
{
return;
@@ -130,7 +141,8 @@ public partial class AuditLogPage
SourceSiteIds: sites,
Target: target,
Actor: actor,
CorrelationId: correlationId);
CorrelationId: correlationId,
ExecutionId: executionId);
}
/// <summary>
@@ -236,6 +248,10 @@ public partial class AuditLogPage
{
parts.Add(new("correlationId", corr.ToString()));
}
if (filter.ExecutionId is { } exec)
{
parts.Add(new("executionId", exec.ToString()));
}
if (filter.FromUtc is { } from)
{
parts.Add(new("from", from.ToString("O", CultureInfo.InvariantCulture)));

View File

@@ -26,6 +26,13 @@ public sealed record AuditEvent
/// <summary>Correlation id linking related audit rows (e.g. the cached-op lifecycle).</summary>
public Guid? CorrelationId { get; init; }
/// <summary>
/// Id of the originating script execution / inbound request — the universal
/// per-run correlation value, distinct from <see cref="CorrelationId"/> (which
/// is the per-operation lifecycle id).
/// </summary>
public Guid? ExecutionId { get; init; }
/// <summary>Site id where the action originated; null for central-direct events.</summary>
public string? SourceSiteId { get; init; }

View File

@@ -27,6 +27,15 @@ public class Notification
public string SourceSiteId { get; set; }
public string? SourceInstanceId { get; set; }
public string? SourceScript { get; set; }
/// <summary>
/// The originating script execution's <c>ExecutionId</c> (Audit Log #23). Carried from
/// the site on the <see cref="Commons.Messages.Notification.NotificationSubmit"/> so the
/// central dispatcher can stamp the same id onto its <c>NotifyDeliver</c> audit rows,
/// correlating them with the site-emitted <c>NotifySend</c> row. Null for notifications
/// submitted before the column existed, or raised outside a script-execution context.
/// </summary>
public Guid? OriginExecutionId { get; set; }
public DateTimeOffset SiteEnqueuedAt { get; set; }
/// <summary>Central ingest time.</summary>

View File

@@ -57,6 +57,20 @@ public interface ICachedCallLifecycleObserver
/// <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>
/// <param name="ExecutionId">
/// Audit Log #23 (ExecutionId Task 4): the originating script execution's
/// per-run correlation id, threaded through the store-and-forward buffer from
/// the cached-call enqueue path. The audit bridge stamps it onto the
/// retry-loop <c>ApiCallCached</c>/<c>DbWriteCached</c> Attempted and
/// <c>CachedResolve</c> rows so they correlate with the rest of the run.
/// <c>null</c> for rows buffered before Task 4 (back-compat).
/// </param>
/// <param name="SourceScript">
/// Audit Log #23 (ExecutionId Task 4): the originating script identifier,
/// threaded alongside <paramref name="ExecutionId"/> so the retry-loop audit
/// rows carry the same <c>SourceScript</c> provenance the script-side cached
/// rows already do. <c>null</c> when not known.
/// </param>
public sealed record CachedCallAttemptContext(
TrackedOperationId TrackedOperationId,
string Channel,
@@ -69,7 +83,9 @@ public sealed record CachedCallAttemptContext(
DateTime CreatedAtUtc,
DateTime OccurredAtUtc,
int? DurationMs,
string? SourceInstanceId);
string? SourceInstanceId,
Guid? ExecutionId = null,
string? SourceScript = null);
/// <summary>
/// Coarse outcome of one cached-call delivery attempt, observed from inside

View File

@@ -29,11 +29,24 @@ public interface IDatabaseGateway
/// <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>
/// <param name="executionId">
/// Audit Log #23 (ExecutionId Task 4): the originating script execution's
/// per-run correlation id. When the write is buffered on a transient
/// failure this is threaded onto the S&amp;F message so the retry-loop
/// cached-write audit rows carry it. <c>null</c> when not threaded.
/// </param>
/// <param name="sourceScript">
/// Audit Log #23 (ExecutionId Task 4): the originating script identifier,
/// threaded onto the buffered S&amp;F message alongside
/// <paramref name="executionId"/>. <c>null</c> when not known.
/// </param>
Task CachedWriteAsync(
string connectionName,
string sql,
IReadOnlyDictionary<string, object?>? parameters = null,
string? originInstanceName = null,
CancellationToken cancellationToken = default,
TrackedOperationId? trackedOperationId = null);
TrackedOperationId? trackedOperationId = null,
Guid? executionId = null,
string? sourceScript = null);
}

View File

@@ -30,13 +30,26 @@ public interface IExternalSystemClient
/// M3 telemetry is correlated (the legacy behaviour pre-M3 callers rely
/// on).
/// </param>
/// <param name="executionId">
/// Audit Log #23 (ExecutionId Task 4): the originating script execution's
/// per-run correlation id. When the call is buffered on a transient
/// failure this is threaded onto the S&amp;F message so the retry-loop
/// cached-call audit rows carry it. <c>null</c> when not threaded.
/// </param>
/// <param name="sourceScript">
/// Audit Log #23 (ExecutionId Task 4): the originating script identifier,
/// threaded onto the buffered S&amp;F message alongside
/// <paramref name="executionId"/>. <c>null</c> when not known.
/// </param>
Task<ExternalCallResult> CachedCallAsync(
string systemName,
string methodName,
IReadOnlyDictionary<string, object?>? parameters = null,
string? originInstanceName = null,
CancellationToken cancellationToken = default,
TrackedOperationId? trackedOperationId = null);
TrackedOperationId? trackedOperationId = null,
Guid? executionId = null,
string? sourceScript = null);
}
/// <summary>

View File

@@ -4,6 +4,13 @@ namespace ScadaLink.Commons.Messages.Notification;
/// Site -> Central: submit a notification for central delivery.
/// Fire-and-forget with ack; the site retries until a <see cref="NotificationSubmitAck"/> is received.
/// </summary>
/// <param name="OriginExecutionId">
/// The originating script execution's <c>ExecutionId</c> (Audit Log #23). Stamped at
/// <c>Notify.Send</c> time and carried, inside the serialized payload, through the site
/// store-and-forward buffer so the central dispatcher can echo it onto the
/// <c>NotifyDeliver</c> audit rows. Additive trailing member — null for messages built
/// before the field existed, or for notifications raised outside a script execution.
/// </param>
public record NotificationSubmit(
string NotificationId,
string ListName,
@@ -12,7 +19,8 @@ public record NotificationSubmit(
string SourceSiteId,
string? SourceInstanceId,
string? SourceScript,
DateTimeOffset SiteEnqueuedAt);
DateTimeOffset SiteEnqueuedAt,
Guid? OriginExecutionId = null);
/// <summary>
/// Central -> Site: ack sent after the notification row is persisted.

View File

@@ -11,7 +11,9 @@ namespace ScadaLink.Commons.Types.Audit;
/// dimension (translated to a SQL <c>IN (…)</c>). 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 dimensions are AND-combined with one another.
/// respectively. All filter dimensions are AND-combined with one another. The
/// single-value <see cref="CorrelationId"/> and <see cref="ExecutionId"/>
/// dimensions constrain on equality when set.
/// </summary>
public sealed record AuditLogQueryFilter(
IReadOnlyList<AuditChannel>? Channels = null,
@@ -21,5 +23,6 @@ public sealed record AuditLogQueryFilter(
string? Target = null,
string? Actor = null,
Guid? CorrelationId = null,
Guid? ExecutionId = null,
DateTime? FromUtc = null,
DateTime? ToUtc = null);

View File

@@ -47,6 +47,7 @@ public static class AuditEventDtoMapper
Channel = evt.Channel.ToString(),
Kind = evt.Kind.ToString(),
CorrelationId = evt.CorrelationId?.ToString() ?? string.Empty,
ExecutionId = evt.ExecutionId?.ToString() ?? string.Empty,
SourceSiteId = evt.SourceSiteId ?? string.Empty,
SourceInstanceId = evt.SourceInstanceId ?? string.Empty,
SourceScript = evt.SourceScript ?? string.Empty,
@@ -92,6 +93,7 @@ public static class AuditEventDtoMapper
Channel = Enum.Parse<AuditChannel>(dto.Channel),
Kind = Enum.Parse<AuditKind>(dto.Kind),
CorrelationId = NullIfEmpty(dto.CorrelationId) is { } cid ? Guid.Parse(cid) : null,
ExecutionId = NullIfEmpty(dto.ExecutionId) is { } eid ? Guid.Parse(eid) : null,
SourceSiteId = NullIfEmpty(dto.SourceSiteId),
SourceInstanceId = NullIfEmpty(dto.SourceInstanceId),
SourceScript = NullIfEmpty(dto.SourceScript),

View File

@@ -91,6 +91,7 @@ message AuditEventDto {
string response_summary = 17;
bool payload_truncated = 18;
string extra = 19;
string execution_id = 20; // empty string represents null
}
message AuditEventBatch { repeated AuditEventDto events = 1; }

View File

@@ -41,7 +41,7 @@ namespace ScadaLink.Communication.Grpc {
"c3RhdGUYAyABKA4yGi5zaXRlc3RyZWFtLkFsYXJtU3RhdGVFbnVtEhAKCHBy",
"aW9yaXR5GAQgASgFEi0KCXRpbWVzdGFtcBgFIAEoCzIaLmdvb2dsZS5wcm90",
"b2J1Zi5UaW1lc3RhbXASKQoFbGV2ZWwYBiABKA4yGi5zaXRlc3RyZWFtLkFs",
"YXJtTGV2ZWxFbnVtEg8KB21lc3NhZ2UYByABKAki9QMKDUF1ZGl0RXZlbnRE",
"YXJtTGV2ZWxFbnVtEg8KB21lc3NhZ2UYByABKAkiiwQKDUF1ZGl0RXZlbnRE",
"dG8SEAoIZXZlbnRfaWQYASABKAkSMwoPb2NjdXJyZWRfYXRfdXRjGAIgASgL",
"MhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIPCgdjaGFubmVsGAMgASgJ",
"EgwKBGtpbmQYBCABKAkSFgoOY29ycmVsYXRpb25faWQYBSABKAkSFgoOc291",
@@ -52,43 +52,43 @@ namespace ScadaLink.Communication.Grpc {
"GA0gASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDMyVmFsdWUSFQoNZXJyb3Jf",
"bWVzc2FnZRgOIAEoCRIUCgxlcnJvcl9kZXRhaWwYDyABKAkSFwoPcmVxdWVz",
"dF9zdW1tYXJ5GBAgASgJEhgKEHJlc3BvbnNlX3N1bW1hcnkYESABKAkSGQoR",
"cGF5bG9hZF90cnVuY2F0ZWQYEiABKAgSDQoFZXh0cmEYEyABKAkiPAoPQXVk",
"aXRFdmVudEJhdGNoEikKBmV2ZW50cxgBIAMoCzIZLnNpdGVzdHJlYW0uQXVk",
"aXRFdmVudER0byInCglJbmdlc3RBY2sSGgoSYWNjZXB0ZWRfZXZlbnRfaWRz",
"GAEgAygJIvQCChZTaXRlQ2FsbE9wZXJhdGlvbmFsRHRvEhwKFHRyYWNrZWRf",
"b3BlcmF0aW9uX2lkGAEgASgJEg8KB2NoYW5uZWwYAiABKAkSDgoGdGFyZ2V0",
"GAMgASgJEhMKC3NvdXJjZV9zaXRlGAQgASgJEg4KBnN0YXR1cxgFIAEoCRIT",
"CgtyZXRyeV9jb3VudBgGIAEoBRISCgpsYXN0X2Vycm9yGAcgASgJEjAKC2h0",
"dHBfc3RhdHVzGAggASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDMyVmFsdWUS",
"MgoOY3JlYXRlZF9hdF91dGMYCSABKAsyGi5nb29nbGUucHJvdG9idWYuVGlt",
"ZXN0YW1wEjIKDnVwZGF0ZWRfYXRfdXRjGAogASgLMhouZ29vZ2xlLnByb3Rv",
"YnVmLlRpbWVzdGFtcBIzCg90ZXJtaW5hbF9hdF91dGMYCyABKAsyGi5nb29n",
"bGUucHJvdG9idWYuVGltZXN0YW1wIoABChVDYWNoZWRUZWxlbWV0cnlQYWNr",
"ZXQSLgoLYXVkaXRfZXZlbnQYASABKAsyGS5zaXRlc3RyZWFtLkF1ZGl0RXZl",
"bnREdG8SNwoLb3BlcmF0aW9uYWwYAiABKAsyIi5zaXRlc3RyZWFtLlNpdGVD",
"YWxsT3BlcmF0aW9uYWxEdG8iSgoUQ2FjaGVkVGVsZW1ldHJ5QmF0Y2gSMgoH",
"cGFja2V0cxgBIAMoCzIhLnNpdGVzdHJlYW0uQ2FjaGVkVGVsZW1ldHJ5UGFj",
"a2V0IlsKFlB1bGxBdWRpdEV2ZW50c1JlcXVlc3QSLQoJc2luY2VfdXRjGAEg",
"ASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBISCgpiYXRjaF9zaXpl",
"GAIgASgFIlwKF1B1bGxBdWRpdEV2ZW50c1Jlc3BvbnNlEikKBmV2ZW50cxgB",
"IAMoCzIZLnNpdGVzdHJlYW0uQXVkaXRFdmVudER0bxIWCg5tb3JlX2F2YWls",
"YWJsZRgCIAEoCCpcCgdRdWFsaXR5EhcKE1FVQUxJVFlfVU5TUEVDSUZJRUQQ",
"ABIQCgxRVUFMSVRZX0dPT0QQARIVChFRVUFMSVRZX1VOQ0VSVEFJThACEg8K",
"C1FVQUxJVFlfQkFEEAMqXQoOQWxhcm1TdGF0ZUVudW0SGwoXQUxBUk1fU1RB",
"VEVfVU5TUEVDSUZJRUQQABIWChJBTEFSTV9TVEFURV9OT1JNQUwQARIWChJB",
"TEFSTV9TVEFURV9BQ1RJVkUQAiqFAQoOQWxhcm1MZXZlbEVudW0SFAoQQUxB",
"Uk1fTEVWRUxfTk9ORRAAEhMKD0FMQVJNX0xFVkVMX0xPVxABEhcKE0FMQVJN",
"X0xFVkVMX0xPV19MT1cQAhIUChBBTEFSTV9MRVZFTF9ISUdIEAMSGQoVQUxB",
"Uk1fTEVWRUxfSElHSF9ISUdIEAQy4QIKEVNpdGVTdHJlYW1TZXJ2aWNlElUK",
"EVN1YnNjcmliZUluc3RhbmNlEiEuc2l0ZXN0cmVhbS5JbnN0YW5jZVN0cmVh",
"bVJlcXVlc3QaGy5zaXRlc3RyZWFtLlNpdGVTdHJlYW1FdmVudDABEkcKEUlu",
"Z2VzdEF1ZGl0RXZlbnRzEhsuc2l0ZXN0cmVhbS5BdWRpdEV2ZW50QmF0Y2ga",
"FS5zaXRlc3RyZWFtLkluZ2VzdEFjaxJQChVJbmdlc3RDYWNoZWRUZWxlbWV0",
"cnkSIC5zaXRlc3RyZWFtLkNhY2hlZFRlbGVtZXRyeUJhdGNoGhUuc2l0ZXN0",
"cmVhbS5Jbmdlc3RBY2sSWgoPUHVsbEF1ZGl0RXZlbnRzEiIuc2l0ZXN0cmVh",
"bS5QdWxsQXVkaXRFdmVudHNSZXF1ZXN0GiMuc2l0ZXN0cmVhbS5QdWxsQXVk",
"aXRFdmVudHNSZXNwb25zZUIfqgIcU2NhZGFMaW5rLkNvbW11bmljYXRpb24u",
"R3JwY2IGcHJvdG8z"));
"cGF5bG9hZF90cnVuY2F0ZWQYEiABKAgSDQoFZXh0cmEYEyABKAkSFAoMZXhl",
"Y3V0aW9uX2lkGBQgASgJIjwKD0F1ZGl0RXZlbnRCYXRjaBIpCgZldmVudHMY",
"ASADKAsyGS5zaXRlc3RyZWFtLkF1ZGl0RXZlbnREdG8iJwoJSW5nZXN0QWNr",
"EhoKEmFjY2VwdGVkX2V2ZW50X2lkcxgBIAMoCSL0AgoWU2l0ZUNhbGxPcGVy",
"YXRpb25hbER0bxIcChR0cmFja2VkX29wZXJhdGlvbl9pZBgBIAEoCRIPCgdj",
"aGFubmVsGAIgASgJEg4KBnRhcmdldBgDIAEoCRITCgtzb3VyY2Vfc2l0ZRgE",
"IAEoCRIOCgZzdGF0dXMYBSABKAkSEwoLcmV0cnlfY291bnQYBiABKAUSEgoK",
"bGFzdF9lcnJvchgHIAEoCRIwCgtodHRwX3N0YXR1cxgIIAEoCzIbLmdvb2ds",
"ZS5wcm90b2J1Zi5JbnQzMlZhbHVlEjIKDmNyZWF0ZWRfYXRfdXRjGAkgASgL",
"MhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIyCg51cGRhdGVkX2F0X3V0",
"YxgKIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASMwoPdGVybWlu",
"YWxfYXRfdXRjGAsgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKA",
"AQoVQ2FjaGVkVGVsZW1ldHJ5UGFja2V0Ei4KC2F1ZGl0X2V2ZW50GAEgASgL",
"Mhkuc2l0ZXN0cmVhbS5BdWRpdEV2ZW50RHRvEjcKC29wZXJhdGlvbmFsGAIg",
"ASgLMiIuc2l0ZXN0cmVhbS5TaXRlQ2FsbE9wZXJhdGlvbmFsRHRvIkoKFENh",
"Y2hlZFRlbGVtZXRyeUJhdGNoEjIKB3BhY2tldHMYASADKAsyIS5zaXRlc3Ry",
"ZWFtLkNhY2hlZFRlbGVtZXRyeVBhY2tldCJbChZQdWxsQXVkaXRFdmVudHNS",
"ZXF1ZXN0Ei0KCXNpbmNlX3V0YxgBIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5U",
"aW1lc3RhbXASEgoKYmF0Y2hfc2l6ZRgCIAEoBSJcChdQdWxsQXVkaXRFdmVu",
"dHNSZXNwb25zZRIpCgZldmVudHMYASADKAsyGS5zaXRlc3RyZWFtLkF1ZGl0",
"RXZlbnREdG8SFgoObW9yZV9hdmFpbGFibGUYAiABKAgqXAoHUXVhbGl0eRIX",
"ChNRVUFMSVRZX1VOU1BFQ0lGSUVEEAASEAoMUVVBTElUWV9HT09EEAESFQoR",
"UVVBTElUWV9VTkNFUlRBSU4QAhIPCgtRVUFMSVRZX0JBRBADKl0KDkFsYXJt",
"U3RhdGVFbnVtEhsKF0FMQVJNX1NUQVRFX1VOU1BFQ0lGSUVEEAASFgoSQUxB",
"Uk1fU1RBVEVfTk9STUFMEAESFgoSQUxBUk1fU1RBVEVfQUNUSVZFEAIqhQEK",
"DkFsYXJtTGV2ZWxFbnVtEhQKEEFMQVJNX0xFVkVMX05PTkUQABITCg9BTEFS",
"TV9MRVZFTF9MT1cQARIXChNBTEFSTV9MRVZFTF9MT1dfTE9XEAISFAoQQUxB",
"Uk1fTEVWRUxfSElHSBADEhkKFUFMQVJNX0xFVkVMX0hJR0hfSElHSBAEMuEC",
"ChFTaXRlU3RyZWFtU2VydmljZRJVChFTdWJzY3JpYmVJbnN0YW5jZRIhLnNp",
"dGVzdHJlYW0uSW5zdGFuY2VTdHJlYW1SZXF1ZXN0Ghsuc2l0ZXN0cmVhbS5T",
"aXRlU3RyZWFtRXZlbnQwARJHChFJbmdlc3RBdWRpdEV2ZW50cxIbLnNpdGVz",
"dHJlYW0uQXVkaXRFdmVudEJhdGNoGhUuc2l0ZXN0cmVhbS5Jbmdlc3RBY2sS",
"UAoVSW5nZXN0Q2FjaGVkVGVsZW1ldHJ5EiAuc2l0ZXN0cmVhbS5DYWNoZWRU",
"ZWxlbWV0cnlCYXRjaBoVLnNpdGVzdHJlYW0uSW5nZXN0QWNrEloKD1B1bGxB",
"dWRpdEV2ZW50cxIiLnNpdGVzdHJlYW0uUHVsbEF1ZGl0RXZlbnRzUmVxdWVz",
"dBojLnNpdGVzdHJlYW0uUHVsbEF1ZGl0RXZlbnRzUmVzcG9uc2VCH6oCHFNj",
"YWRhTGluay5Db21tdW5pY2F0aW9uLkdycGNiBnByb3RvMw=="));
descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData,
new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.WrappersReflection.Descriptor, },
new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ScadaLink.Communication.Grpc.Quality), typeof(global::ScadaLink.Communication.Grpc.AlarmStateEnum), typeof(global::ScadaLink.Communication.Grpc.AlarmLevelEnum), }, null, new pbr::GeneratedClrTypeInfo[] {
@@ -96,7 +96,7 @@ namespace ScadaLink.Communication.Grpc {
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.SiteStreamEvent), global::ScadaLink.Communication.Grpc.SiteStreamEvent.Parser, new[]{ "CorrelationId", "AttributeChanged", "AlarmChanged" }, new[]{ "Event" }, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AttributeValueUpdate), global::ScadaLink.Communication.Grpc.AttributeValueUpdate.Parser, new[]{ "InstanceUniqueName", "AttributePath", "AttributeName", "Value", "Quality", "Timestamp" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AlarmStateUpdate), global::ScadaLink.Communication.Grpc.AlarmStateUpdate.Parser, new[]{ "InstanceUniqueName", "AlarmName", "State", "Priority", "Timestamp", "Level", "Message" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AuditEventDto), global::ScadaLink.Communication.Grpc.AuditEventDto.Parser, new[]{ "EventId", "OccurredAtUtc", "Channel", "Kind", "CorrelationId", "SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target", "Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AuditEventDto), global::ScadaLink.Communication.Grpc.AuditEventDto.Parser, new[]{ "EventId", "OccurredAtUtc", "Channel", "Kind", "CorrelationId", "SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target", "Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra", "ExecutionId" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AuditEventBatch), global::ScadaLink.Communication.Grpc.AuditEventBatch.Parser, new[]{ "Events" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.IngestAck), global::ScadaLink.Communication.Grpc.IngestAck.Parser, new[]{ "AcceptedEventIds" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.SiteCallOperationalDto), global::ScadaLink.Communication.Grpc.SiteCallOperationalDto.Parser, new[]{ "TrackedOperationId", "Channel", "Target", "SourceSite", "Status", "RetryCount", "LastError", "HttpStatus", "CreatedAtUtc", "UpdatedAtUtc", "TerminalAtUtc" }, null, null, null, null),
@@ -1591,6 +1591,7 @@ namespace ScadaLink.Communication.Grpc {
responseSummary_ = other.responseSummary_;
payloadTruncated_ = other.payloadTruncated_;
extra_ = other.extra_;
executionId_ = other.executionId_;
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
}
@@ -1838,6 +1839,21 @@ namespace ScadaLink.Communication.Grpc {
}
}
/// <summary>Field number for the "execution_id" field.</summary>
public const int ExecutionIdFieldNumber = 20;
private string executionId_ = "";
/// <summary>
/// empty string represents null
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public string ExecutionId {
get { return executionId_; }
set {
executionId_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
}
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public override bool Equals(object other) {
@@ -1872,6 +1888,7 @@ namespace ScadaLink.Communication.Grpc {
if (ResponseSummary != other.ResponseSummary) return false;
if (PayloadTruncated != other.PayloadTruncated) return false;
if (Extra != other.Extra) return false;
if (ExecutionId != other.ExecutionId) return false;
return Equals(_unknownFields, other._unknownFields);
}
@@ -1898,6 +1915,7 @@ namespace ScadaLink.Communication.Grpc {
if (ResponseSummary.Length != 0) hash ^= ResponseSummary.GetHashCode();
if (PayloadTruncated != false) hash ^= PayloadTruncated.GetHashCode();
if (Extra.Length != 0) hash ^= Extra.GetHashCode();
if (ExecutionId.Length != 0) hash ^= ExecutionId.GetHashCode();
if (_unknownFields != null) {
hash ^= _unknownFields.GetHashCode();
}
@@ -1990,6 +2008,10 @@ namespace ScadaLink.Communication.Grpc {
output.WriteRawTag(154, 1);
output.WriteString(Extra);
}
if (ExecutionId.Length != 0) {
output.WriteRawTag(162, 1);
output.WriteString(ExecutionId);
}
if (_unknownFields != null) {
_unknownFields.WriteTo(output);
}
@@ -2074,6 +2096,10 @@ namespace ScadaLink.Communication.Grpc {
output.WriteRawTag(154, 1);
output.WriteString(Extra);
}
if (ExecutionId.Length != 0) {
output.WriteRawTag(162, 1);
output.WriteString(ExecutionId);
}
if (_unknownFields != null) {
_unknownFields.WriteTo(ref output);
}
@@ -2141,6 +2167,9 @@ namespace ScadaLink.Communication.Grpc {
if (Extra.Length != 0) {
size += 2 + pb::CodedOutputStream.ComputeStringSize(Extra);
}
if (ExecutionId.Length != 0) {
size += 2 + pb::CodedOutputStream.ComputeStringSize(ExecutionId);
}
if (_unknownFields != null) {
size += _unknownFields.CalculateSize();
}
@@ -2217,6 +2246,9 @@ namespace ScadaLink.Communication.Grpc {
if (other.Extra.Length != 0) {
Extra = other.Extra;
}
if (other.ExecutionId.Length != 0) {
ExecutionId = other.ExecutionId;
}
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
}
@@ -2321,6 +2353,10 @@ namespace ScadaLink.Communication.Grpc {
Extra = input.ReadString();
break;
}
case 162: {
ExecutionId = input.ReadString();
break;
}
}
}
#endif
@@ -2425,6 +2461,10 @@ namespace ScadaLink.Communication.Grpc {
Extra = input.ReadString();
break;
}
case 162: {
ExecutionId = input.ReadString();
break;
}
}
}
}

View File

@@ -89,6 +89,10 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditEve
.HasFilter("[CorrelationId] IS NOT NULL")
.HasDatabaseName("IX_AuditLog_CorrelationId");
builder.HasIndex(e => e.ExecutionId)
.HasFilter("[ExecutionId] IS NOT NULL")
.HasDatabaseName("IX_AuditLog_Execution");
builder.HasIndex(e => new { e.Channel, e.Status, e.OccurredAtUtc })
.IsDescending(false, false, true)
.HasDatabaseName("IX_AuditLog_Channel_Status_Occurred");

View File

@@ -47,6 +47,10 @@ public class NotificationOutboxConfiguration : IEntityTypeConfiguration<Notifica
builder.Property(n => n.SourceScript).HasMaxLength(200);
// OriginExecutionId (Audit Log #23): nullable uniqueidentifier carried from the
// site so the dispatcher can echo it onto NotifyDeliver audit rows. No index —
// it is never a query predicate on this table, only copied onto audit events.
builder.HasIndex(n => new { n.Status, n.NextAttemptAt });
builder.HasIndex(n => new { n.SourceSiteId, n.CreatedAt });

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ScadaLink.ConfigurationDatabase.Migrations
{
/// <summary>
/// Adds the universal <c>ExecutionId</c> correlation column to the centralized
/// <c>AuditLog</c> table (#23). <c>ExecutionId</c> identifies the originating
/// script execution / inbound request and is distinct from the per-operation
/// <c>CorrelationId</c>.
///
/// The change is purely additive:
/// 1. <c>ExecutionId uniqueidentifier NULL</c> is added with no default, so the
/// operation is a metadata-only <c>ALTER TABLE … ADD</c> — it does NOT
/// rewrite the monthly-partitioned <c>AuditLog</c> table, and historical
/// rows stay <c>NULL</c> (no backfill).
/// 2. <c>IX_AuditLog_Execution</c> is created via raw SQL so it lands on the
/// <c>ps_AuditLog_Month(OccurredAtUtc)</c> partition scheme, matching every
/// other <c>IX_AuditLog_*</c> index. Keeping it partition-aligned preserves
/// the partition-switch purge path (see AuditLogRepository.SwitchOutPartitionAsync).
/// </summary>
public partial class AddAuditLogExecutionId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "ExecutionId",
table: "AuditLog",
type: "uniqueidentifier",
nullable: true);
// Raw SQL so the index is created on the partition scheme — EF's
// CreateIndex cannot express the ON ps_AuditLog_Month(OccurredAtUtc)
// clause. Mirrors IX_AuditLog_CorrelationId (filtered, aligned).
migrationBuilder.Sql(@"
CREATE NONCLUSTERED INDEX IX_AuditLog_Execution
ON dbo.AuditLog (ExecutionId)
WHERE ExecutionId IS NOT NULL
ON ps_AuditLog_Month(OccurredAtUtc);");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_Execution' AND object_id = OBJECT_ID('dbo.AuditLog'))
DROP INDEX IX_AuditLog_Execution ON dbo.AuditLog;");
migrationBuilder.DropColumn(
name: "ExecutionId",
table: "AuditLog");
}
}
}

View File

@@ -0,0 +1,41 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ScadaLink.ConfigurationDatabase.Migrations
{
/// <summary>
/// Adds the <c>OriginExecutionId</c> correlation column to the central
/// <c>Notifications</c> table (#21). It carries the originating script execution's
/// <c>ExecutionId</c> from the site so the dispatcher can echo it onto the
/// <c>NotifyDeliver</c> audit rows (#23), linking them to the site's <c>NotifySend</c>
/// row for the same run.
///
/// The change is purely additive: <c>OriginExecutionId uniqueidentifier NULL</c> is
/// added with no default, so the operation is a metadata-only <c>ALTER TABLE … ADD</c>.
/// Unlike <c>AuditLog</c>, the <c>Notifications</c> table is NOT partitioned, so a
/// plain <c>ADD</c> is fine. No index is created — the column is never a query
/// predicate, only copied onto audit events. Historical rows stay <c>NULL</c>.
/// </summary>
public partial class AddNotificationOriginExecutionId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "OriginExecutionId",
table: "Notifications",
type: "uniqueidentifier",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "OriginExecutionId",
table: "Notifications");
}
}
}

View File

@@ -73,6 +73,9 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<Guid?>("ExecutionId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Extra")
.HasColumnType("nvarchar(max)");
@@ -138,6 +141,10 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
.IsUnique()
.HasDatabaseName("UX_AuditLog_EventId");
b.HasIndex("ExecutionId")
.HasDatabaseName("IX_AuditLog_Execution")
.HasFilter("[ExecutionId] IS NOT NULL");
b.HasIndex("OccurredAtUtc")
.IsDescending()
.HasDatabaseName("IX_AuditLog_OccurredAtUtc");
@@ -780,6 +787,9 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
b.Property<DateTimeOffset?>("NextAttemptAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("OriginExecutionId")
.HasColumnType("uniqueidentifier");
b.Property<string>("ResolvedTargets")
.HasColumnType("nvarchar(max)");

View File

@@ -64,12 +64,12 @@ public class AuditLogRepository : IAuditLogRepository
await _context.Database.ExecuteSqlInterpolatedAsync(
$@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId})
INSERT INTO dbo.AuditLog
(EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId,
(EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId, ExecutionId,
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status,
HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary,
ResponseSummary, PayloadTruncated, Extra, ForwardState)
VALUES
({evt.EventId}, {evt.OccurredAtUtc}, {evt.IngestedAtUtc}, {channel}, {kind}, {evt.CorrelationId},
({evt.EventId}, {evt.OccurredAtUtc}, {evt.IngestedAtUtc}, {channel}, {kind}, {evt.CorrelationId}, {evt.ExecutionId},
{evt.SourceSiteId}, {evt.SourceInstanceId}, {evt.SourceScript}, {evt.Actor}, {evt.Target}, {status},
{evt.HttpStatus}, {evt.DurationMs}, {evt.ErrorMessage}, {evt.ErrorDetail}, {evt.RequestSummary},
{evt.ResponseSummary}, {evt.PayloadTruncated}, {evt.Extra}, {forwardState});",
@@ -157,6 +157,11 @@ VALUES
query = query.Where(e => e.CorrelationId == correlationId);
}
if (filter.ExecutionId is { } executionId)
{
query = query.Where(e => e.ExecutionId == executionId);
}
if (filter.FromUtc is { } fromUtc)
{
query = query.Where(e => e.OccurredAtUtc >= fromUtc);
@@ -263,6 +268,10 @@ VALUES
PayloadTruncated bit NOT NULL,
Extra nvarchar(max) NULL,
ForwardState varchar(32) NULL,
-- ExecutionId is last because it was added to the live AuditLog table by a later
-- ALTER TABLE ADD migration; the staging table must match the live table column
-- shape ordinal-for-ordinal or ALTER TABLE ... SWITCH PARTITION fails.
ExecutionId uniqueidentifier NULL,
CONSTRAINT PK_{stagingTableName} PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc)
) ON [PRIMARY];

View File

@@ -84,7 +84,9 @@ public class DatabaseGateway : IDatabaseGateway
IReadOnlyDictionary<string, object?>? parameters = null,
string? originInstanceName = null,
CancellationToken cancellationToken = default,
TrackedOperationId? trackedOperationId = null)
TrackedOperationId? trackedOperationId = null,
Guid? executionId = null,
string? sourceScript = null)
{
var definition = await ResolveConnectionAsync(connectionName, cancellationToken);
if (definition == null)
@@ -124,7 +126,13 @@ public class DatabaseGateway : IDatabaseGateway
// 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());
messageId: trackedOperationId?.ToString(),
// Audit Log #23 (ExecutionId Task 4): thread the originating script
// execution's ExecutionId + SourceScript onto the buffered row so
// the retry-loop cached-write audit rows carry the same provenance
// the script-side cached rows do.
executionId: executionId,
sourceScript: sourceScript);
}
/// <summary>

View File

@@ -86,7 +86,9 @@ public class ExternalSystemClient : IExternalSystemClient
IReadOnlyDictionary<string, object?>? parameters = null,
string? originInstanceName = null,
CancellationToken cancellationToken = default,
TrackedOperationId? trackedOperationId = null)
TrackedOperationId? trackedOperationId = null,
Guid? executionId = null,
string? sourceScript = null)
{
var (system, method) = await ResolveSystemAndMethodAsync(systemName, methodName, cancellationToken);
if (system == null || method == null)
@@ -144,7 +146,13 @@ public class ExternalSystemClient : IExternalSystemClient
// 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());
messageId: trackedOperationId?.ToString(),
// Audit Log #23 (ExecutionId Task 4): thread the originating
// script execution's ExecutionId + SourceScript onto the
// buffered row so the retry-loop cached-call audit rows carry
// the same provenance the script-side cached rows do.
executionId: executionId,
sourceScript: sourceScript);
return new ExternalCallResult(true, null, null, WasBuffered: true);
}

View File

@@ -145,17 +145,21 @@ public sealed class AuditWriteMiddleware
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiInbound,
Kind = kind,
// Audit Log #23: a fresh per-request correlation id so the
// Audit Log #23: a fresh per-request execution id so the
// inbound row carries a request identifier (closes the design
// gap that inbound rows should be correlatable).
//
// This id is intentionally request-local: it is NOT bridged to
// RouteHelper's routed-call correlation id or to
// HttpContext.TraceIdentifier. Threading an inbound request's
// correlation id through to the routed script execution (so an
// execution id through to the routed script execution (so an
// inbound call and the outbound API/DB rows it triggers share
// one id) is a deliberate future follow-up, out of scope here.
CorrelationId = Guid.NewGuid(),
ExecutionId = Guid.NewGuid(),
// CorrelationId is purely the per-operation-lifecycle id; an
// inbound request is a one-shot from the audit row's
// perspective with no multi-row operation to correlate.
CorrelationId = null,
Actor = actor,
Target = methodName,
Status = status,

View File

@@ -395,6 +395,13 @@ public static class AuditEndpoints
correlationId = parsedCorr;
}
Guid? executionId = null;
if (query.TryGetValue("executionId", out var execValues)
&& Guid.TryParse(execValues.ToString(), out var parsedExec))
{
executionId = parsedExec;
}
return new AuditLogQueryFilter(
Channels: channels,
Kinds: kinds,
@@ -403,6 +410,7 @@ public static class AuditEndpoints
Target: TrimToNullable(query, "target"),
Actor: TrimToNullable(query, "actor"),
CorrelationId: correlationId,
ExecutionId: executionId,
FromUtc: ParseUtcDate(query, "fromUtc"),
ToUtc: ParseUtcDate(query, "toUtc"));
}

View File

@@ -489,6 +489,10 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
/// parses the notification's id as a Guid; sites generate the id with
/// <c>Guid.NewGuid().ToString("N")</c> so the parse always succeeds, but
/// a non-Guid id is recorded as null rather than crashing the dispatcher.
/// <see cref="AuditEvent.ExecutionId"/> is copied straight from
/// <see cref="Notification.OriginExecutionId"/> so the dispatcher's
/// <c>NotifyDeliver</c> rows carry the same per-run id as the site's
/// <c>NotifySend</c> row (Audit Log #23).
/// </summary>
private static AuditEvent BuildNotifyDeliverEvent(
Notification notification,
@@ -515,6 +519,12 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
SourceSiteId = notification.SourceSiteId,
SourceInstanceId = notification.SourceInstanceId,
SourceScript = notification.SourceScript,
// ExecutionId (Audit Log #23): the originating script execution's id,
// carried from the site on NotificationSubmit and persisted on the
// Notification row. Echoing it here links the central NotifyDeliver
// rows to the site-emitted NotifySend row for the same run. Null when
// the notification was raised outside a script execution.
ExecutionId = notification.OriginExecutionId,
Target = notification.ListName,
Status = status,
ErrorMessage = errorMessage,
@@ -941,6 +951,9 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
{
SourceInstanceId = msg.SourceInstanceId,
SourceScript = msg.SourceScript,
// OriginExecutionId (Audit Log #23): the originating script execution's id,
// carried from the site so the dispatcher can echo it onto NotifyDeliver rows.
OriginExecutionId = msg.OriginExecutionId,
SiteEnqueuedAt = msg.SiteEnqueuedAt,
CreatedAt = DateTimeOffset.UtcNow,
// Status stays at its Pending default for the dispatch sweep to claim.

View File

@@ -37,11 +37,11 @@ internal sealed class AuditingDbCommand : DbCommand
private readonly string _siteId;
private readonly string _instanceName;
private readonly string? _sourceScript;
private readonly Guid _auditCorrelationId;
private readonly Guid _executionId;
private readonly ILogger _logger;
private DbConnection? _wrappingConnection;
// Parameter ordering: auditCorrelationId sits immediately after the ILogger,
// Parameter ordering: executionId sits immediately after the ILogger,
// consistent with the other three audit-threaded ctors (ExternalSystemHelper,
// DatabaseHelper, AuditingDbConnection).
public AuditingDbCommand(
@@ -52,7 +52,7 @@ internal sealed class AuditingDbCommand : DbCommand
string instanceName,
string? sourceScript,
ILogger logger,
Guid auditCorrelationId)
Guid executionId)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
@@ -61,7 +61,7 @@ internal sealed class AuditingDbCommand : DbCommand
_instanceName = instanceName ?? string.Empty;
_sourceScript = sourceScript;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_auditCorrelationId = auditCorrelationId;
_executionId = executionId;
}
// -- Forwarded surface ------------------------------------------------
@@ -432,10 +432,12 @@ internal sealed class AuditingDbCommand : DbCommand
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.DbOutbound,
Kind = AuditKind.DbWrite,
// Audit Log #23: the execution-wide correlation id, so this sync
// DbWrite row shares an id with the other sync trust-boundary rows
// from the same script run.
CorrelationId = _auditCorrelationId,
// Audit Log #23: a sync one-shot DB write has no operation
// lifecycle, so CorrelationId is null. ExecutionId carries the
// per-execution id so this row shares an id with the other sync
// trust-boundary rows from the same script run.
CorrelationId = null,
ExecutionId = _executionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,

View File

@@ -36,10 +36,10 @@ internal sealed class AuditingDbConnection : DbConnection
private readonly string _siteId;
private readonly string _instanceName;
private readonly string? _sourceScript;
private readonly Guid _auditCorrelationId;
private readonly Guid _executionId;
private readonly ILogger _logger;
// Parameter ordering: auditCorrelationId sits immediately after the ILogger,
// Parameter ordering: executionId sits immediately after the ILogger,
// consistent with the other three audit-threaded ctors (ExternalSystemHelper,
// DatabaseHelper, AuditingDbCommand).
public AuditingDbConnection(
@@ -50,7 +50,7 @@ internal sealed class AuditingDbConnection : DbConnection
string instanceName,
string? sourceScript,
ILogger logger,
Guid auditCorrelationId)
Guid executionId)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
_auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
@@ -59,7 +59,7 @@ internal sealed class AuditingDbConnection : DbConnection
_instanceName = instanceName ?? string.Empty;
_sourceScript = sourceScript;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_auditCorrelationId = auditCorrelationId;
_executionId = executionId;
}
// ConnectionString is settable on DbConnection — forward both halves.
@@ -99,7 +99,7 @@ internal sealed class AuditingDbConnection : DbConnection
_instanceName,
_sourceScript,
_logger,
_auditCorrelationId);
_executionId);
}
protected override void Dispose(bool disposing)

View File

@@ -106,19 +106,22 @@ public class ScriptRuntimeContext
private readonly ICachedCallTelemetryForwarder? _cachedForwarder;
/// <summary>
/// Audit Log #23: the execution-wide audit correlation id. Every sync
/// Audit Log #23: the per-execution id for this script run. Every
/// trust-boundary audit row emitted by this script execution
/// (<c>ApiCall</c>, <c>DbWrite</c>) is stamped with this id so all the
/// rows from one script run can be correlated together.
/// (sync <c>ApiCall</c>/<c>DbWrite</c>, cached-call lifecycle rows,
/// <c>NotifySend</c>) is stamped into <c>AuditEvent.ExecutionId</c> with
/// this value so all the rows from one script run can be correlated
/// together — independently of the per-operation
/// <c>AuditEvent.CorrelationId</c>.
/// </summary>
private readonly Guid _auditCorrelationId;
private readonly Guid _executionId;
/// <param name="auditCorrelationId">
/// Audit Log #23: the execution-wide audit correlation id. When omitted
/// <param name="executionId">
/// Audit Log #23: the per-execution id for this script run. When omitted
/// (tag-change / timer-triggered executions) a fresh id is generated; an
/// inbound caller may supply one to tie the execution to an upstream
/// request. Stamped on the sync <c>ApiCall</c>/<c>DbWrite</c> audit rows
/// this execution emits.
/// request. Stamped into <c>AuditEvent.ExecutionId</c> on every
/// trust-boundary audit row this execution emits.
/// </param>
public ScriptRuntimeContext(
IActorRef instanceActor,
@@ -138,7 +141,7 @@ public class ScriptRuntimeContext
IAuditWriter? auditWriter = null,
IOperationTrackingStore? operationTrackingStore = null,
ICachedCallTelemetryForwarder? cachedForwarder = null,
Guid? auditCorrelationId = null)
Guid? executionId = null)
{
_instanceActor = instanceActor;
_self = self;
@@ -157,7 +160,7 @@ public class ScriptRuntimeContext
_auditWriter = auditWriter;
_operationTrackingStore = operationTrackingStore;
_cachedForwarder = cachedForwarder;
_auditCorrelationId = auditCorrelationId ?? Guid.NewGuid();
_executionId = executionId ?? Guid.NewGuid();
}
/// <summary>
@@ -258,7 +261,7 @@ public class ScriptRuntimeContext
/// ExternalSystem.CachedCall("systemName", "methodName", params)
/// </summary>
public ExternalSystemHelper ExternalSystem => new(
_externalSystemClient, _instanceName, _logger, _auditCorrelationId, _auditWriter, _siteId, _sourceScript,
_externalSystemClient, _instanceName, _logger, _executionId, _auditWriter, _siteId, _sourceScript,
// Audit Log #23 (M3 Bundle E — Task E3): emit CachedSubmit telemetry
// on every ExternalSystem.CachedCall enqueue.
_cachedForwarder);
@@ -272,7 +275,7 @@ public class ScriptRuntimeContext
_databaseGateway,
_instanceName,
_logger,
_auditCorrelationId,
_executionId,
// Audit Log #23 (M4 Bundle A): wire the IAuditWriter so
// Database.Connection(name) returns an auditing decorator that
// emits one DbOutbound/DbWrite row per script-initiated
@@ -299,7 +302,7 @@ public class ScriptRuntimeContext
/// </remarks>
public NotifyHelper Notify => new(
_storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _sourceScript, _askTimeout, _logger,
_auditWriter);
_executionId, _auditWriter);
/// <summary>
/// Audit Log #23 (M3): site-local tracking-status API for cached operations.
@@ -380,7 +383,7 @@ public class ScriptRuntimeContext
private readonly IExternalSystemClient? _client;
private readonly string _instanceName;
private readonly ILogger _logger;
private readonly Guid _auditCorrelationId;
private readonly Guid _executionId;
private readonly IAuditWriter? _auditWriter;
private readonly string _siteId;
private readonly string? _sourceScript;
@@ -390,7 +393,7 @@ public class ScriptRuntimeContext
// (via InternalsVisibleTo). Production sites resolve the helper through
// ScriptRuntimeContext.ExternalSystem.
//
// Parameter ordering: auditCorrelationId sits immediately after the
// Parameter ordering: executionId sits immediately after the
// ILogger across all four audit-threaded ctors (ExternalSystemHelper,
// DatabaseHelper, AuditingDbConnection, AuditingDbCommand) — a required
// Guid cannot follow the optional provenance params without a
@@ -400,7 +403,7 @@ public class ScriptRuntimeContext
IExternalSystemClient? client,
string instanceName,
ILogger logger,
Guid auditCorrelationId,
Guid executionId,
IAuditWriter? auditWriter = null,
string siteId = "",
string? sourceScript = null,
@@ -409,7 +412,7 @@ public class ScriptRuntimeContext
_client = client;
_instanceName = instanceName;
_logger = logger;
_auditCorrelationId = auditCorrelationId;
_executionId = executionId;
_auditWriter = auditWriter;
_siteId = siteId;
_sourceScript = sourceScript;
@@ -510,7 +513,12 @@ public class ScriptRuntimeContext
parameters,
_instanceName,
cancellationToken,
trackedId).ConfigureAwait(false);
trackedId,
// Audit Log #23 (ExecutionId Task 4): thread the script
// execution's ExecutionId + SourceScript so a buffered
// cached call's retry-loop audit rows carry them.
executionId: _executionId,
sourceScript: _sourceScript).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -567,7 +575,11 @@ public class ScriptRuntimeContext
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.CachedSubmit,
// CorrelationId stays the per-operation lifecycle id
// (TrackedOperationId); ExecutionId carries the
// per-execution id shared across this script run.
CorrelationId = trackedId.Value,
ExecutionId = _executionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
@@ -677,7 +689,10 @@ public class ScriptRuntimeContext
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCallCached,
// CorrelationId = per-operation lifecycle id;
// ExecutionId = per-execution id for this script run.
CorrelationId = trackedId.Value,
ExecutionId = _executionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
@@ -738,7 +753,10 @@ public class ScriptRuntimeContext
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.CachedResolve,
// CorrelationId = per-operation lifecycle id;
// ExecutionId = per-execution id for this script run.
CorrelationId = trackedId.Value,
ExecutionId = _executionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
@@ -910,9 +928,12 @@ public class ScriptRuntimeContext
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
// Audit Log #23: the execution-wide correlation id, so all the
// sync ApiCall/DbWrite rows from one script run share an id.
CorrelationId = _auditCorrelationId,
// Audit Log #23: a sync one-shot call has no operation
// lifecycle, so CorrelationId is null. ExecutionId carries the
// per-execution id so all the sync ApiCall/DbWrite rows from
// one script run can be correlated together.
CorrelationId = null,
ExecutionId = _executionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
@@ -979,7 +1000,7 @@ public class ScriptRuntimeContext
private readonly IDatabaseGateway? _gateway;
private readonly string _instanceName;
private readonly ILogger _logger;
private readonly Guid _auditCorrelationId;
private readonly Guid _executionId;
private readonly string _siteId;
private readonly string? _sourceScript;
private readonly ICachedCallTelemetryForwarder? _cachedForwarder;
@@ -996,7 +1017,7 @@ public class ScriptRuntimeContext
/// </summary>
private readonly IAuditWriter? _auditWriter;
// Parameter ordering: auditCorrelationId sits immediately after the
// Parameter ordering: executionId sits immediately after the
// ILogger — see the note on ExternalSystemHelper's ctor for why the
// post-logger slot is the one consistent position across all four
// audit-threaded ctors.
@@ -1004,7 +1025,7 @@ public class ScriptRuntimeContext
IDatabaseGateway? gateway,
string instanceName,
ILogger logger,
Guid auditCorrelationId,
Guid executionId,
IAuditWriter? auditWriter = null,
string siteId = "",
string? sourceScript = null,
@@ -1013,7 +1034,7 @@ public class ScriptRuntimeContext
_gateway = gateway;
_instanceName = instanceName;
_logger = logger;
_auditCorrelationId = auditCorrelationId;
_executionId = executionId;
_auditWriter = auditWriter;
_siteId = siteId;
_sourceScript = sourceScript;
@@ -1049,7 +1070,7 @@ public class ScriptRuntimeContext
instanceName: _instanceName,
sourceScript: _sourceScript,
logger: _logger,
auditCorrelationId: _auditCorrelationId);
executionId: _executionId);
}
/// <summary>
@@ -1080,7 +1101,12 @@ public class ScriptRuntimeContext
try
{
await _gateway.CachedWriteAsync(
name, sql, parameters, _instanceName, cancellationToken, trackedId)
name, sql, parameters, _instanceName, cancellationToken, trackedId,
// Audit Log #23 (ExecutionId Task 4): thread the script
// execution's ExecutionId + SourceScript so a buffered
// cached write's retry-loop audit rows carry them.
executionId: _executionId,
sourceScript: _sourceScript)
.ConfigureAwait(false);
}
catch (Exception ex)
@@ -1116,7 +1142,10 @@ public class ScriptRuntimeContext
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.DbOutbound,
Kind = AuditKind.CachedSubmit,
// CorrelationId = per-operation lifecycle id
// (TrackedOperationId); ExecutionId = per-execution id.
CorrelationId = trackedId.Value,
ExecutionId = _executionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,
@@ -1178,6 +1207,12 @@ public class ScriptRuntimeContext
private readonly TimeSpan _askTimeout;
private readonly ILogger _logger;
/// <summary>
/// Audit Log #23: the per-execution id for this script run, stamped
/// into <c>AuditEvent.ExecutionId</c> on the <c>NotifySend</c> row.
/// </summary>
private readonly Guid _executionId;
/// <summary>
/// Audit Log #23 (M4 Bundle C): best-effort emitter for the
/// <c>Notification</c>/<c>NotifySend</c> row produced when the script
@@ -1188,6 +1223,8 @@ public class ScriptRuntimeContext
/// </summary>
private readonly IAuditWriter? _auditWriter;
// Parameter ordering: executionId sits immediately after the ILogger,
// consistent with the other audit-threaded ctors.
internal NotifyHelper(
StoreAndForwardService? storeAndForward,
ICanTell? siteCommunicationActor,
@@ -1196,6 +1233,7 @@ public class ScriptRuntimeContext
string? sourceScript,
TimeSpan askTimeout,
ILogger logger,
Guid executionId,
IAuditWriter? auditWriter = null)
{
_storeAndForward = storeAndForward;
@@ -1205,6 +1243,7 @@ public class ScriptRuntimeContext
_sourceScript = sourceScript;
_askTimeout = askTimeout;
_logger = logger;
_executionId = executionId;
_auditWriter = auditWriter;
}
@@ -1215,6 +1254,9 @@ public class ScriptRuntimeContext
{
return new NotifyTarget(
listName, _storeAndForward, _siteId, _instanceName, _sourceScript, _logger,
// Audit Log #23: the per-execution id stamped into the
// NotifySend row's ExecutionId column.
_executionId,
// Audit Log #23 (M4 Bundle C): forward the writer so Send()
// can emit one NotifySend(Submitted) row per accepted submission.
_auditWriter);
@@ -1292,6 +1334,12 @@ public class ScriptRuntimeContext
private readonly string? _sourceScript;
private readonly ILogger _logger;
/// <summary>
/// Audit Log #23: the per-execution id for this script run, stamped
/// into <c>AuditEvent.ExecutionId</c> on the <c>NotifySend</c> row.
/// </summary>
private readonly Guid _executionId;
/// <summary>
/// Audit Log #23 (M4 Bundle C): best-effort emitter for the
/// <c>Notification</c>/<c>NotifySend</c> row written immediately after
@@ -1307,6 +1355,7 @@ public class ScriptRuntimeContext
string instanceName,
string? sourceScript,
ILogger logger,
Guid executionId,
IAuditWriter? auditWriter = null)
{
_listName = listName;
@@ -1315,6 +1364,7 @@ public class ScriptRuntimeContext
_instanceName = instanceName;
_sourceScript = sourceScript;
_logger = logger;
_executionId = executionId;
_auditWriter = auditWriter;
}
@@ -1357,7 +1407,12 @@ public class ScriptRuntimeContext
// notification, threaded down from the script-execution context for the
// central audit trail. Null when no single script owns the context.
SourceScript: _sourceScript,
SiteEnqueuedAt: DateTimeOffset.UtcNow);
SiteEnqueuedAt: DateTimeOffset.UtcNow,
// OriginExecutionId (Audit Log #23): the SAME per-execution id stamped
// onto this run's NotifySend audit row. It rides inside the serialized
// payload through the S&F buffer to central, where the dispatcher echoes
// it onto the NotifyDeliver rows so all rows for one run share an id.
OriginExecutionId: _executionId);
var payloadJson = JsonSerializer.Serialize(payload);
@@ -1431,7 +1486,10 @@ public class ScriptRuntimeContext
OccurredAtUtc = DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
Channel = AuditChannel.Notification,
Kind = AuditKind.NotifySend,
// CorrelationId is the NotificationId-derived per-operation
// lifecycle id; ExecutionId carries the per-execution id.
CorrelationId = correlationId,
ExecutionId = _executionId,
SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId,
SourceInstanceId = _instanceName,
SourceScript = _sourceScript,

View File

@@ -55,4 +55,25 @@ public class StoreAndForwardMessage
/// WP-13: Messages are NOT cleared when instance is deleted.
/// </summary>
public string? OriginInstanceName { get; set; }
/// <summary>
/// Audit Log #23 (ExecutionId Task 4): the originating script execution's
/// per-run correlation id, threaded from <c>ScriptRuntimeContext</c> through
/// the cached-call enqueue path. Carried so the store-and-forward retry loop
/// can stamp it onto the per-attempt / terminal cached-call audit rows
/// (<c>ApiCallCached</c>/<c>DbWriteCached</c> Attempted, <c>CachedResolve</c>).
/// <c>null</c> for non-cached-call categories (notifications) and for rows
/// buffered before this field existed — back-compat with old persisted rows
/// (the column is added by an additive migration and read as null when absent).
/// </summary>
public Guid? ExecutionId { get; set; }
/// <summary>
/// Audit Log #23 (ExecutionId Task 4): the originating script identifier,
/// threaded alongside <see cref="ExecutionId"/> from the cached-call enqueue
/// path so the retry-loop audit rows carry the same <c>SourceScript</c>
/// provenance the script-side cached rows already carry. <c>null</c> when not
/// known (non-cached categories, pre-migration rows).
/// </summary>
public string? SourceScript { get; set; }
}

View File

@@ -175,6 +175,18 @@ public class StoreAndForwardService
/// it is the buffered row's <see cref="StoreAndForwardMessage.Id"/>, it is carried
/// inside the payload, and it is the id the forwarder submits to central.
/// </param>
/// <param name="executionId">
/// Audit Log #23 (ExecutionId Task 4): the originating script execution's
/// per-run correlation id. Threaded onto the buffered row so the retry-loop
/// cached-call audit rows carry it. <c>null</c> for callers (notifications,
/// pre-Task-4 callers) that do not supply one.
/// </param>
/// <param name="sourceScript">
/// Audit Log #23 (ExecutionId Task 4): the originating script identifier,
/// threaded onto the buffered row alongside <paramref name="executionId"/>
/// so the retry-loop audit rows carry the same provenance the script-side
/// cached rows do. <c>null</c> when not known.
/// </param>
public async Task<StoreAndForwardResult> EnqueueAsync(
StoreAndForwardCategory category,
string target,
@@ -183,7 +195,9 @@ public class StoreAndForwardService
int? maxRetries = null,
TimeSpan? retryInterval = null,
bool attemptImmediateDelivery = true,
string? messageId = null)
string? messageId = null,
Guid? executionId = null,
string? sourceScript = null)
{
var message = new StoreAndForwardMessage
{
@@ -196,7 +210,9 @@ public class StoreAndForwardService
RetryIntervalMs = (long)(retryInterval ?? _options.DefaultRetryInterval).TotalMilliseconds,
CreatedAt = DateTimeOffset.UtcNow,
Status = StoreAndForwardMessageStatus.Pending,
OriginInstanceName = originInstanceName
OriginInstanceName = originInstanceName,
ExecutionId = executionId,
SourceScript = sourceScript
};
// Attempt immediate delivery — unless the caller has already made a
@@ -492,7 +508,14 @@ public class StoreAndForwardService
CreatedAtUtc: message.CreatedAt.UtcDateTime,
OccurredAtUtc: DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
DurationMs: durationMs,
SourceInstanceId: message.OriginInstanceName);
SourceInstanceId: message.OriginInstanceName,
// Audit Log #23 (ExecutionId Task 4): the buffered message
// carries the originating script execution's ExecutionId +
// SourceScript; surface them on the context so the bridge can
// stamp the retry-loop cached audit rows. Null on rows buffered
// before Task 4 (back-compat).
ExecutionId: message.ExecutionId,
SourceScript: message.SourceScript);
}
catch (Exception buildEx)
{

View File

@@ -65,9 +65,45 @@ public class StoreAndForwardStorage
";
await command.ExecuteNonQueryAsync();
// Audit Log #23 (ExecutionId Task 4): additively add the execution_id /
// source_script columns. CREATE TABLE IF NOT EXISTS above does NOT add
// columns to a table that already exists from before these fields, so a
// databases created by an older build needs the columns ALTER-ed in.
// SQLite has no "ADD COLUMN IF NOT EXISTS"; the column presence is
// probed first and the ALTER skipped when already there. Both columns
// are nullable with no default, so any row buffered before this
// migration reads back ExecutionId/SourceScript = null (back-compat).
await AddColumnIfMissingAsync(connection, "execution_id", "TEXT");
await AddColumnIfMissingAsync(connection, "source_script", "TEXT");
_logger.LogInformation("Store-and-forward SQLite storage initialized");
}
/// <summary>
/// Audit Log #23 (ExecutionId Task 4): adds a column to <c>sf_messages</c>
/// only when it is not already present. SQLite lacks <c>ADD COLUMN IF NOT
/// EXISTS</c>, so the schema is probed via <c>PRAGMA table_info</c> first.
/// Idempotent — safe to run on every <see cref="InitializeAsync"/>.
/// </summary>
private static async Task AddColumnIfMissingAsync(
SqliteConnection connection, string columnName, string columnType)
{
await using var probe = connection.CreateCommand();
probe.CommandText = "SELECT COUNT(*) FROM pragma_table_info('sf_messages') WHERE name = @name";
probe.Parameters.AddWithValue("@name", columnName);
var exists = Convert.ToInt32(await probe.ExecuteScalarAsync()) > 0;
if (exists)
{
return;
}
await using var alter = connection.CreateCommand();
// Column name + type are caller-controlled constants, never user input —
// safe to interpolate (parameters are not permitted in DDL).
alter.CommandText = $"ALTER TABLE sf_messages ADD COLUMN {columnName} {columnType}";
await alter.ExecuteNonQueryAsync();
}
/// <summary>
/// Ensures the directory for a file-backed SQLite database exists. SQLite creates
/// the database file on demand but not its parent directory, so a configured path
@@ -105,9 +141,11 @@ public class StoreAndForwardStorage
await using var cmd = connection.CreateCommand();
cmd.CommandText = @"
INSERT INTO sf_messages (id, category, target, payload_json, retry_count, max_retries,
retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance)
retry_interval_ms, created_at, last_attempt_at, status, last_error,
origin_instance, execution_id, source_script)
VALUES (@id, @category, @target, @payload, @retryCount, @maxRetries,
@retryIntervalMs, @createdAt, @lastAttempt, @status, @lastError, @origin)";
@retryIntervalMs, @createdAt, @lastAttempt, @status, @lastError,
@origin, @executionId, @sourceScript)";
cmd.Parameters.AddWithValue("@id", message.Id);
cmd.Parameters.AddWithValue("@category", (int)message.Category);
@@ -122,6 +160,12 @@ public class StoreAndForwardStorage
cmd.Parameters.AddWithValue("@status", (int)message.Status);
cmd.Parameters.AddWithValue("@lastError", (object?)message.LastError ?? DBNull.Value);
cmd.Parameters.AddWithValue("@origin", (object?)message.OriginInstanceName ?? DBNull.Value);
// Audit Log #23 (ExecutionId Task 4): the execution id is stored as its
// canonical string form ("D") so it round-trips cleanly through the
// TEXT column; null when not a cached call / not threaded.
cmd.Parameters.AddWithValue("@executionId",
message.ExecutionId.HasValue ? message.ExecutionId.Value.ToString("D") : DBNull.Value);
cmd.Parameters.AddWithValue("@sourceScript", (object?)message.SourceScript ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync();
}
@@ -137,7 +181,8 @@ public class StoreAndForwardStorage
await using var cmd = connection.CreateCommand();
cmd.CommandText = @"
SELECT id, category, target, payload_json, retry_count, max_retries,
retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance
retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance,
execution_id, source_script
FROM sf_messages
WHERE status = @pending
AND (last_attempt_at IS NULL
@@ -268,7 +313,8 @@ public class StoreAndForwardStorage
var categoryFilter = category.HasValue ? " AND category = @category" : "";
pageCmd.CommandText = $@"
SELECT id, category, target, payload_json, retry_count, max_retries,
retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance
retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance,
execution_id, source_script
FROM sf_messages
WHERE status = @parked{categoryFilter}
ORDER BY created_at ASC
@@ -389,7 +435,8 @@ public class StoreAndForwardStorage
await using var cmd = connection.CreateCommand();
cmd.CommandText = @"
SELECT id, category, target, payload_json, retry_count, max_retries,
retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance
retry_interval_ms, created_at, last_attempt_at, status, last_error, origin_instance,
execution_id, source_script
FROM sf_messages
WHERE id = @id";
cmd.Parameters.AddWithValue("@id", messageId);
@@ -446,9 +493,35 @@ public class StoreAndForwardStorage
LastAttemptAt = reader.IsDBNull(8) ? null : DateTimeOffset.Parse(reader.GetString(8)),
Status = (StoreAndForwardMessageStatus)reader.GetInt32(9),
LastError = reader.IsDBNull(10) ? null : reader.GetString(10),
OriginInstanceName = reader.IsDBNull(11) ? null : reader.GetString(11)
OriginInstanceName = reader.IsDBNull(11) ? null : reader.GetString(11),
// Audit Log #23 (ExecutionId Task 4): rows persisted before the
// additive migration have no execution_id / source_script value;
// IsDBNull guards keep those reading back as null (back-compat).
// Guid.TryParse (not Parse) guards the retry sweep: a corrupt
// non-null execution_id is treated as "no execution id" rather
// than throwing FormatException and aborting the whole sweep.
ExecutionId = ParseExecutionId(reader, 12),
SourceScript = reader.IsDBNull(13) ? null : reader.GetString(13)
});
}
return results;
}
/// <summary>
/// Audit Log #23 (ExecutionId Task 4): defensively reads the
/// <c>execution_id</c> column. A <c>null</c> value (legacy pre-migration
/// rows) and a malformed non-null value both yield <c>null</c> — a corrupt
/// id must not throw and abort the retry sweep, which reads many rows.
/// </summary>
private static Guid? ParseExecutionId(System.Data.Common.DbDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal))
{
return null;
}
return Guid.TryParse(reader.GetString(ordinal), out var executionId)
? executionId
: null;
}
}

View File

@@ -0,0 +1,274 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ScadaLink.AuditLog.Central;
using ScadaLink.AuditLog.Site;
using ScadaLink.AuditLog.Site.Telemetry;
using ScadaLink.AuditLog.Tests.Integration.Infrastructure;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Types.Audit;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.ConfigurationDatabase.Repositories;
using ScadaLink.ConfigurationDatabase.Tests.Migrations;
using ScadaLink.SiteRuntime.Scripts;
namespace ScadaLink.AuditLog.Tests.Integration;
/// <summary>
/// Audit Log #23 — ExecutionId end-to-end correlation suite verifying the
/// universal per-run correlation promise: <b>every audit row produced by one
/// script execution carries the same non-null <see cref="AuditEvent.ExecutionId"/></b>.
/// </summary>
/// <remarks>
/// <para>
/// This is the integration-level counterpart to the unit-level
/// <c>ExecutionCorrelationContextTests</c> in <c>ScadaLink.SiteRuntime.Tests</c>:
/// where that test asserts the shared id on the in-memory captured rows, this
/// suite drives the rows all the way through the production pipeline — the real
/// <see cref="SqliteAuditWriter"/> site hot-path, the real
/// <see cref="SiteAuditTelemetryActor"/> drain loop, the real
/// <see cref="AuditLogIngestActor"/>, and the real <see cref="AuditLogRepository"/>
/// over the per-class <see cref="MsSqlMigrationFixture"/> MSSQL database — then
/// reads the rows back from the central store and asserts the shared id.
/// </para>
/// <para>
/// Composes the same pipeline as the M2 <see cref="SyncCallEmissionEndToEndTests"/>
/// and the M4 <see cref="DatabaseSyncEmissionEndToEndTests"/>: an in-memory
/// <see cref="SqliteAuditWriter"/> + <see cref="RingBufferFallback"/> +
/// <see cref="FallbackAuditWriter"/> on the site, drained by a real
/// <see cref="SiteAuditTelemetryActor"/> through the shared
/// <see cref="DirectActorSiteStreamAuditClient"/> stub that short-circuits the
/// gRPC wire and Asks the central ingest actor. The production
/// <see cref="ScriptRuntimeContext"/> is driven directly: one context performs
/// two distinct trust-boundary actions — a sync <c>ExternalSystem.Call</c> and a
/// sync <c>Database</c> write — so the two emitted audit rows originate from one
/// execution. Each test uses a unique <c>ExecutionId</c> + <c>SourceSiteId</c>
/// (Guid suffixes) so concurrent tests sharing the MSSQL fixture don't interfere.
/// </para>
/// </remarks>
public class ExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public ExecutionIdCorrelationTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
private const string ConnectionName = "machineData";
private const string InstanceName = "Plant.Pump42";
private const string SourceScript = "ScriptActor:OnTick";
private static string NewSiteId() =>
"test-execid-" + Guid.NewGuid().ToString("N").Substring(0, 8);
private ScadaLinkDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.Options;
return new ScadaLinkDbContext(options);
}
/// <summary>
/// Per-test in-memory SQLite database with a tiny single-table schema the
/// sync DB write targets. The keep-alive root pins the in-memory file for
/// the duration of the test; the returned <c>live</c> connection is what the
/// stub gateway hands back to the auditing wrapper. Mirrors
/// <c>DatabaseSyncEmissionEndToEndTests.NewInMemoryDb</c>.
/// </summary>
private static SqliteConnection NewInMemoryDb(out SqliteConnection keepAlive)
{
var dbName = $"db-{Guid.NewGuid():N}";
var connStr = $"Data Source={dbName};Mode=Memory;Cache=Shared";
keepAlive = new SqliteConnection(connStr);
keepAlive.Open();
using (var seed = keepAlive.CreateCommand())
{
seed.CommandText =
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT NOT NULL);";
seed.ExecuteNonQuery();
}
var live = new SqliteConnection(connStr);
live.Open();
return live;
}
private static SqliteAuditWriter CreateInMemorySqliteWriter() =>
new(
Options.Create(new SqliteAuditWriterOptions
{
DatabasePath = "ignored",
BatchSize = 64,
ChannelCapacity = 1024,
}),
NullLogger<SqliteAuditWriter>.Instance,
connectionStringOverride:
$"Data Source=file:auditlog-execid-{Guid.NewGuid():N}?mode=memory&cache=shared");
private static IOptions<SiteAuditTelemetryOptions> FastTelemetryOptions() =>
Options.Create(new SiteAuditTelemetryOptions
{
BatchSize = 256,
// 1s on both intervals so the initial scheduled tick fires quickly
// — drains the SQLite Pending rows and pushes them through the stub
// gRPC client into the central ingest actor.
BusyIntervalSeconds = 1,
IdleIntervalSeconds = 1,
});
private IActorRef CreateIngestActor(IAuditLogRepository repo) =>
Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
repo,
NullLogger<AuditLogIngestActor>.Instance)));
private IActorRef CreateTelemetryActor(
ISiteAuditQueue queue,
ISiteStreamAuditClient client) =>
Sys.ActorOf(Props.Create(() => new SiteAuditTelemetryActor(
queue,
client,
FastTelemetryOptions(),
NullLogger<SiteAuditTelemetryActor>.Instance)));
[SkippableFact]
public async Task OneExecution_ApiCallAndDbWrite_AllCentralRows_ShareOneNonNullExecutionId()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
// An explicit per-run execution id — the value the test asserts on every
// audit row produced by the single script execution below.
var executionId = Guid.NewGuid();
// ── Central — repository + ingest actor backed by the MSSQL fixture ──
await using var ingestContext = CreateContext();
var ingestRepo = new AuditLogRepository(ingestContext);
var ingestActor = CreateIngestActor(ingestRepo);
// ── Site — SQLite audit writer + ring + fallback + telemetry actor ───
await using var sqliteWriter = CreateInMemorySqliteWriter();
var ring = new RingBufferFallback();
var fallback = new FallbackAuditWriter(
sqliteWriter,
ring,
new NoOpAuditWriteFailureCounter(),
NullLogger<FallbackAuditWriter>.Instance);
var stubClient = new DirectActorSiteStreamAuditClient(ingestActor);
CreateTelemetryActor(sqliteWriter, stubClient);
// Outbound API client — one successful CallAsync, one audit row.
var externalClient = Substitute.For<IExternalSystemClient>();
externalClient
.CallAsync("ERP", "GetOrder", Arg.Any<IReadOnlyDictionary<string, object?>?>(), Arg.Any<CancellationToken>())
.Returns(new ExternalCallResult(true, "{}", null));
// SQLite-backed inner DB connection — the stub gateway hands it to the
// auditing wrapper as the connection the script would have got.
using var keepAlive = new SqliteConnection("Data Source=execid-k1;Mode=Memory;Cache=Shared");
var innerDb = NewInMemoryDb(out _);
var gateway = Substitute.For<IDatabaseGateway>();
gateway.GetConnectionAsync(ConnectionName, Arg.Any<CancellationToken>())
.Returns(innerDb);
// ── Act — ONE script execution: a sync ExternalSystem.Call AND a sync
// Database write, both performed through a SINGLE ScriptRuntimeContext
// stamped with the explicit executionId. Each helper emits exactly one
// trust-boundary audit row to the fallback writer; the telemetry actor's
// next tick drains both to central.
var context = CreateScriptContext(externalClient, gateway, fallback, siteId, executionId);
await context.ExternalSystem.Call("ERP", "GetOrder");
await using (var conn = await context.Database.Connection(ConnectionName))
await using (var cmd = conn.CreateCommand())
{
cmd.CommandText = "INSERT INTO t (id, name) VALUES (1, 'alpha')";
var affected = await cmd.ExecuteNonQueryAsync();
Assert.Equal(1, affected);
}
// ── Assert — read the rows back from the CENTRAL store filtered by the
// execution id; both the ApiCall and the DbWrite row must be present and
// every one must carry the SAME non-null ExecutionId we minted above.
await AwaitAssertAsync(async () =>
{
await using var readContext = CreateContext();
var readRepo = new AuditLogRepository(readContext);
// The ExecutionId filter dimension is the universal-correlation
// query an audit reader uses to pull every action of one run.
var rows = await readRepo.QueryAsync(
new AuditLogQueryFilter(ExecutionId: executionId),
new AuditLogPaging(PageSize: 10));
// Both trust-boundary actions of the one execution have landed.
Assert.Equal(2, rows.Count);
// Every central row carries the SAME non-null ExecutionId — the
// core promise of the per-run correlation value.
Assert.All(rows, r =>
{
Assert.NotNull(r.ExecutionId);
Assert.Equal(executionId, r.ExecutionId);
Assert.Equal(siteId, r.SourceSiteId);
// Central stamps IngestedAtUtc; the site never sets it.
Assert.NotNull(r.IngestedAtUtc);
});
// The two rows are the two distinct trust-boundary actions — one
// outbound API call and one outbound DB write — proving the shared
// id spans different channels, not two rows of the same action.
Assert.Single(rows, r => r.Channel == AuditChannel.ApiOutbound && r.Kind == AuditKind.ApiCall);
Assert.Single(rows, r => r.Channel == AuditChannel.DbOutbound && r.Kind == AuditKind.DbWrite);
}, TimeSpan.FromSeconds(15));
}
/// <summary>
/// Builds a production <see cref="ScriptRuntimeContext"/> wired with the
/// outbound external-system client, the database gateway and the audit
/// writer, stamped with an explicit <paramref name="executionId"/>. The
/// actor refs are <see cref="ActorRefs.Nobody"/> — the ExternalSystem /
/// Database helpers exercised here never touch them.
/// </summary>
private static ScriptRuntimeContext CreateScriptContext(
IExternalSystemClient externalSystemClient,
IDatabaseGateway databaseGateway,
IAuditWriter auditWriter,
string siteId,
Guid executionId)
{
var compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
var sharedScriptLibrary = new SharedScriptLibrary(
compilationService, NullLogger<SharedScriptLibrary>.Instance);
return new ScriptRuntimeContext(
ActorRefs.Nobody,
ActorRefs.Nobody,
sharedScriptLibrary,
currentCallDepth: 0,
maxCallDepth: 10,
askTimeout: TimeSpan.FromSeconds(5),
instanceName: InstanceName,
logger: NullLogger.Instance,
externalSystemClient: externalSystemClient,
databaseGateway: databaseGateway,
storeAndForward: null,
siteCommunicationActor: null,
siteId: siteId,
sourceScript: SourceScript,
auditWriter: auditWriter,
operationTrackingStore: null,
cachedForwarder: null,
executionId: executionId);
}
}

View File

@@ -2,6 +2,8 @@ using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ScadaLink.AuditLog.Site;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.AuditLog.Tests.Site;
@@ -41,9 +43,9 @@ public class SqliteAuditWriterSchemaTests
}
[Fact]
public void Opens_Creates_AuditLog_Table_With_20Columns_And_PK_On_EventId()
public void Opens_Creates_AuditLog_Table_With_21Columns_And_PK_On_EventId()
{
var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_AuditLog_Table_With_20Columns_And_PK_On_EventId));
var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_AuditLog_Table_With_21Columns_And_PK_On_EventId));
using (writer)
{
using var connection = OpenVerifierConnection(dataSource);
@@ -57,7 +59,7 @@ public class SqliteAuditWriterSchemaTests
columns.Add((reader.GetString(1), reader.GetInt32(5)));
}
Assert.Equal(20, columns.Count);
Assert.Equal(21, columns.Count);
var expected = new[]
{
@@ -65,7 +67,7 @@ public class SqliteAuditWriterSchemaTests
"SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target",
"Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail",
"RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra",
"ForwardState",
"ForwardState", "ExecutionId",
};
Assert.Equal(expected.OrderBy(n => n), columns.Select(c => c.Name).OrderBy(n => n));
@@ -125,4 +127,122 @@ public class SqliteAuditWriterSchemaTests
Assert.Equal(2, value);
}
}
// ----- ExecutionId schema-upgrade regression (persistent auditlog.db) ----- //
/// <summary>
/// The OLD pre-ExecutionId-branch <c>AuditLog</c> schema — the 20-column
/// CREATE TABLE WITHOUT the <c>ExecutionId</c> column. A real deployment's
/// on-disk <c>auditlog.db</c> already contains exactly this shape, and
/// <c>CREATE TABLE IF NOT EXISTS</c> is a no-op against it.
/// </summary>
private const string OldPreExecutionIdSchema = """
CREATE TABLE IF NOT EXISTS AuditLog (
EventId TEXT NOT NULL,
OccurredAtUtc TEXT NOT NULL,
Channel TEXT NOT NULL,
Kind TEXT NOT NULL,
CorrelationId TEXT NULL,
SourceSiteId TEXT NULL,
SourceInstanceId TEXT NULL,
SourceScript TEXT NULL,
Actor TEXT NULL,
Target TEXT NULL,
Status TEXT NOT NULL,
HttpStatus INTEGER NULL,
DurationMs INTEGER NULL,
ErrorMessage TEXT NULL,
ErrorDetail TEXT NULL,
RequestSummary TEXT NULL,
ResponseSummary TEXT NULL,
PayloadTruncated INTEGER NOT NULL,
Extra TEXT NULL,
ForwardState TEXT NOT NULL,
PRIMARY KEY (EventId)
);
CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred
ON AuditLog (ForwardState, OccurredAtUtc);
""";
/// <summary>
/// Seeds a shared-cache in-memory database with the OLD 20-column schema and
/// returns the open connection. The connection MUST stay open for the
/// lifetime of the test: a shared-cache in-memory database is dropped once
/// its last connection closes, so closing this would discard the seeded
/// schema before the writer opens its own connection.
/// </summary>
private static SqliteConnection SeedOldSchemaDatabase(string dataSource)
{
var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared");
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = OldPreExecutionIdSchema;
cmd.ExecuteNonQuery();
return connection;
}
private static SqliteAuditWriter CreateWriterOver(string dataSource)
{
var options = new SqliteAuditWriterOptions { DatabasePath = dataSource };
return new SqliteAuditWriter(
Options.Create(options),
NullLogger<SqliteAuditWriter>.Instance,
connectionStringOverride: $"Data Source={dataSource};Cache=Shared");
}
private static bool ColumnExists(SqliteConnection connection, string columnName)
{
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM pragma_table_info('AuditLog') WHERE name = $name";
cmd.Parameters.AddWithValue("$name", columnName);
return Convert.ToInt32(cmd.ExecuteScalar()) > 0;
}
[Fact]
public async Task Opening_Over_PreExisting_OldSchema_Db_Adds_ExecutionId_Column_And_WriteAsync_RoundTrips()
{
var dataSource = $"file:{nameof(Opening_Over_PreExisting_OldSchema_Db_Adds_ExecutionId_Column_And_WriteAsync_RoundTrips)}-{Guid.NewGuid():N}?mode=memory&cache=shared";
// A pre-branch deployment: auditlog.db already exists with the 20-column
// schema and NO ExecutionId column.
using var seedConnection = SeedOldSchemaDatabase(dataSource);
Assert.False(ColumnExists(seedConnection, "ExecutionId"));
// Upgrade: a post-branch SqliteAuditWriter opens the same database. Its
// InitializeSchema must ALTER the missing ExecutionId column in — the
// CREATE TABLE IF NOT EXISTS alone is a no-op against the existing table.
var executionId = Guid.NewGuid();
await using (var writer = CreateWriterOver(dataSource))
{
Assert.True(
ColumnExists(seedConnection, "ExecutionId"),
"SqliteAuditWriter must ALTER the ExecutionId column into a pre-existing AuditLog table.");
// A WriteAsync binding $ExecutionId must now succeed and round-trip;
// without the ALTER it would fail with "no such column: ExecutionId"
// and — because audit writes are best-effort — silently drop the row.
var evt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
PayloadTruncated = false,
ExecutionId = executionId,
};
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Equal(executionId, row.ExecutionId);
}
// Idempotency: a second writer over the now-upgraded DB must not error
// (the probe sees ExecutionId already present and skips the ALTER).
await using (var writerAgain = CreateWriterOver(dataSource))
{
Assert.True(ColumnExists(seedConnection, "ExecutionId"));
}
}
}

View File

@@ -353,4 +353,37 @@ public class SqliteAuditWriterWriteTests
await writer.MarkReconciledAsync(new[] { Guid.NewGuid(), Guid.NewGuid() });
// Completes without throwing.
}
// ----- ExecutionId column (universal per-run correlation value) ----- //
[Fact]
public async Task WriteAsync_NonNullExecutionId_RoundTripsThroughMapRow()
{
var (writer, _) = CreateWriter(nameof(WriteAsync_NonNullExecutionId_RoundTripsThroughMapRow));
await using var _w = writer;
var executionId = Guid.NewGuid();
var evt = NewEvent() with { ExecutionId = executionId };
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Equal(executionId, row.ExecutionId);
}
[Fact]
public async Task WriteAsync_NullExecutionId_RoundTripsAsNull()
{
var (writer, _) = CreateWriter(nameof(WriteAsync_NullExecutionId_RoundTripsAsNull));
await using var _w = writer;
var evt = NewEvent() with { ExecutionId = null };
await writer.WriteAsync(evt);
var rows = await writer.ReadPendingAsync(limit: 10);
var row = Assert.Single(rows);
Assert.Null(row.ExecutionId);
}
}

View File

@@ -31,7 +31,9 @@ public class CachedCallLifecycleBridgeTests
string channel = "ApiOutbound",
int retryCount = 1,
string? lastError = null,
int? httpStatus = null) =>
int? httpStatus = null,
Guid? executionId = null,
string? sourceScript = null) =>
new(
TrackedOperationId: _id,
Channel: channel,
@@ -44,7 +46,9 @@ public class CachedCallLifecycleBridgeTests
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");
SourceInstanceId: "Plant.Pump42",
ExecutionId: executionId,
SourceScript: sourceScript);
[Fact]
public async Task TransientFailure_EmitsOneAttemptedRow_NoResolve()
@@ -184,4 +188,75 @@ public class CachedCallLifecycleBridgeTests
Assert.Equal(42, captured.Audit.DurationMs);
Assert.Equal(_id.Value, captured.Audit.CorrelationId);
}
// ── Audit Log #23 (ExecutionId Task 4): ExecutionId / SourceScript ──
[Fact]
public async Task RetryLoopAttemptedRow_CarriesExecutionIdAndSourceScript_FromContext()
{
// Task 4: the ExecutionId + SourceScript threaded through the S&F
// buffer arrive on the CachedCallAttemptContext; the bridge must stamp
// both onto the per-attempt ApiCallCached row (previously SourceScript
// was hard-coded null with a "not threaded through S&F" comment).
var executionId = Guid.NewGuid();
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,
executionId: executionId,
sourceScript: "Plant.Pump42/OnTick"));
var packet = Assert.Single(captured);
Assert.Equal(AuditKind.ApiCallCached, packet.Audit.Kind);
Assert.Equal(executionId, packet.Audit.ExecutionId);
Assert.Equal("Plant.Pump42/OnTick", packet.Audit.SourceScript);
}
[Fact]
public async Task RetryLoopCachedResolveRow_CarriesExecutionIdAndSourceScript_FromContext()
{
// The terminal CachedResolve row must also carry the threaded
// provenance so the whole retry-loop lifecycle is correlated.
var executionId = Guid.NewGuid();
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",
executionId: executionId,
sourceScript: "Plant.Tank/OnAlarm"));
Assert.Equal(2, captured.Count);
var resolve = Assert.Single(captured, p => p.Audit.Kind == AuditKind.CachedResolve);
Assert.Equal(executionId, resolve.Audit.ExecutionId);
Assert.Equal("Plant.Tank/OnAlarm", resolve.Audit.SourceScript);
var attempted = Assert.Single(captured, p => p.Audit.Kind == AuditKind.DbWriteCached);
Assert.Equal(executionId, attempted.Audit.ExecutionId);
Assert.Equal("Plant.Tank/OnAlarm", attempted.Audit.SourceScript);
}
[Fact]
public async Task RetryLoopRow_NullExecutionIdAndSourceScript_RemainNull()
{
// Back-compat: a pre-Task-4 buffered row has no ExecutionId /
// SourceScript; the bridge must leave the audit row's fields null
// rather than throwing.
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));
Assert.NotNull(captured);
Assert.Null(captured!.Audit.ExecutionId);
Assert.Null(captured.Audit.SourceScript);
}
}

View File

@@ -65,6 +65,7 @@ public class AuditQueryCommandTests
Target = "weather-api",
Actor = "multi-role",
CorrelationId = "abc-123",
ExecutionId = "def-456",
ErrorsOnly = false,
PageSize = 250,
};
@@ -81,6 +82,7 @@ public class AuditQueryCommandTests
Assert.Equal("weather-api", parsed["target"]);
Assert.Equal("multi-role", parsed["actor"]);
Assert.Equal("abc-123", parsed["correlationId"]);
Assert.Equal("def-456", parsed["executionId"]);
Assert.Equal("250", parsed["pageSize"]);
Assert.Equal("2026-05-20T11:00:00.0000000+00:00", parsed["fromUtc"]);
Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]);
@@ -155,9 +157,22 @@ public class AuditQueryCommandTests
Assert.Null(parsed["channel"]);
Assert.Null(parsed["status"]);
Assert.Null(parsed["fromUtc"]);
Assert.Null(parsed["correlationId"]);
Assert.Null(parsed["executionId"]);
Assert.Equal("100", parsed["pageSize"]);
}
[Fact]
public void BuildQueryString_ExecutionId_EmitsExecutionIdParameter()
{
// --execution-id is a single-value Guid filter — mirrors --correlation-id.
var now = DateTimeOffset.UtcNow;
var args = new AuditQueryArgs { ExecutionId = "11111111-1111-1111-1111-111111111111" };
var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null);
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
Assert.Equal("11111111-1111-1111-1111-111111111111", parsed["executionId"]);
}
// ---- HTTP execution / paging ------------------------------------------
private sealed class RecordingHandler : HttpMessageHandler
@@ -281,6 +296,18 @@ public class AuditQueryCommandTests
Assert.Empty(parse.Errors);
}
[Fact]
public void Query_ExecutionIdOption_IsAccepted()
{
// --execution-id is a single-value option — mirrors --correlation-id.
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[]
{
"audit", "query", "--execution-id", "11111111-1111-1111-1111-111111111111",
});
Assert.Empty(parse.Errors);
}
// ---- Enum-name validation (fast-fail) ----------------------------------
[Fact]

View File

@@ -65,6 +65,7 @@ internal static class AuditDataSeeder
string? target = null,
string? actor = null,
Guid? correlationId = null,
Guid? executionId = null,
int? httpStatus = null,
int? durationMs = null,
string? errorMessage = null,
@@ -76,13 +77,13 @@ internal static class AuditDataSeeder
const string sql = @"
INSERT INTO [AuditLog]
([EventId], [OccurredAtUtc], [IngestedAtUtc], [Channel], [Kind], [CorrelationId],
[SourceSiteId], [SourceInstanceId], [SourceScript], [Actor], [Target], [Status],
[HttpStatus], [DurationMs], [ErrorMessage], [ErrorDetail], [RequestSummary],
[ExecutionId], [SourceSiteId], [SourceInstanceId], [SourceScript], [Actor], [Target],
[Status], [HttpStatus], [DurationMs], [ErrorMessage], [ErrorDetail], [RequestSummary],
[ResponseSummary], [PayloadTruncated], [Extra], [ForwardState])
VALUES
(@eventId, @occurredAtUtc, SYSUTCDATETIME(), @channel, @kind, @correlationId,
@sourceSiteId, NULL, NULL, @actor, @target, @status,
@httpStatus, @durationMs, @errorMessage, NULL, @requestSummary,
@executionId, @sourceSiteId, NULL, NULL, @actor, @target,
@status, @httpStatus, @durationMs, @errorMessage, NULL, @requestSummary,
@responseSummary, 0, @extra, NULL);";
await using var connection = new SqlConnection(ConnectionString);
@@ -94,6 +95,7 @@ VALUES
cmd.Parameters.AddWithValue("@channel", channel);
cmd.Parameters.AddWithValue("@kind", kind);
cmd.Parameters.AddWithValue("@correlationId", (object?)correlationId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@executionId", (object?)executionId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@sourceSiteId", (object?)sourceSiteId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@actor", (object?)actor ?? DBNull.Value);
cmd.Parameters.AddWithValue("@target", (object?)target ?? DBNull.Value);

View File

@@ -24,6 +24,9 @@ namespace ScadaLink.CentralUI.PlaywrightTests.Audit;
/// link relies on; verified by reproducing the link target directly because
/// seeding a notification visible to the report page requires the Akka query
/// path, not just an INSERT).</item>
/// <item><c>DrillInFromExecutionId_LandsOnAuditLogWithFilterContext</c> — the
/// <c>?executionId=</c> drill-in (the drawer's "View this execution" action)
/// auto-loads the grid filtered by ExecutionId.</item>
/// <item><c>NotificationsPage_HasViewAuditHistoryLink_WhenNotificationsExist</c> —
/// the report page wires drill-in links when notifications are present.</item>
/// <item><c>ExportCsv_LinkIsVisibleAndDownloads</c> — Export CSV button gated on
@@ -289,6 +292,64 @@ public class AuditLogPageTests
}
}
[Fact]
public async Task DrillInFromExecutionId_LandsOnAuditLogWithFilterContext()
{
// Mirrors the correlationId drill-in: the "View this execution" drawer
// action navigates to /audit/log?executionId={ExecutionId}. We seed a row
// carrying that ExecutionId, hit the deep link directly, and assert the
// page deserializes the param and auto-loads the seeded row.
if (!await AuditDataSeeder.IsAvailableAsync())
{
throw new InvalidOperationException("MSSQL unavailable; see FilterNarrowing test for setup instructions.");
}
var runId = Guid.NewGuid().ToString("N");
var targetPrefix = $"playwright-test/exec-drill-in/{runId}/";
var executionId = Guid.NewGuid();
var eventId = Guid.NewGuid();
var now = DateTime.UtcNow;
try
{
await AuditDataSeeder.InsertAuditEventAsync(
eventId: eventId,
occurredAtUtc: now,
channel: "ApiOutbound",
kind: "ApiCall",
status: "Delivered",
target: targetPrefix + "endpoint",
executionId: executionId,
httpStatus: 200,
durationMs: 11);
var page = await _fixture.NewAuthenticatedPageAsync();
// The exact URL the drawer's "View this execution" button produces.
await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log?executionId={executionId}");
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
Assert.Contains($"executionId={executionId}", page.Url);
await Assertions.Expect(page.Locator("h1:has-text('Audit Log')")).ToBeVisibleAsync();
await Assertions.Expect(page.Locator("[data-test='audit-filter-bar']")).ToBeVisibleAsync();
await Assertions.Expect(page.Locator("[data-test='audit-results-grid']")).ToBeVisibleAsync();
// Auto-load: the query-string drill-in resolves the ?executionId=
// filter on OnInitialized and the seeded row appears without an
// Apply click.
var seededRow = page.Locator($"[data-test='grid-row-{eventId}']");
await Assertions.Expect(seededRow).ToBeVisibleAsync();
// The ExecutionId column renders the row's short-form value.
var execCell = page.Locator($"[data-test='execution-id-{eventId}']");
await Assertions.Expect(execCell).ToBeVisibleAsync();
}
finally
{
await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix);
}
}
[Fact]
public async Task NotificationsPage_RendersAuditDrillInLinkPattern()
{

View File

@@ -138,6 +138,7 @@ public class AuditExportEndpointsTests
using (host)
{
var correlationId = Guid.NewGuid().ToString();
var executionId = Guid.NewGuid().ToString();
var url =
"/api/centralui/audit/export?" +
"channel=ApiOutbound&" +
@@ -147,6 +148,7 @@ public class AuditExportEndpointsTests
"target=PaymentApi&" +
"actor=apikey-1&" +
$"correlationId={correlationId}&" +
$"executionId={executionId}&" +
"from=2026-05-20T00:00:00Z&" +
"to=2026-05-20T23:59:59Z";
@@ -167,6 +169,7 @@ public class AuditExportEndpointsTests
f.Target == "PaymentApi" &&
f.Actor == "apikey-1" &&
f.CorrelationId == Guid.Parse(correlationId) &&
f.ExecutionId == Guid.Parse(executionId) &&
f.FromUtc == new DateTime(2026, 5, 20, 0, 0, 0, DateTimeKind.Utc) &&
f.ToUtc == new DateTime(2026, 5, 20, 23, 59, 59, DateTimeKind.Utc)),
Arg.Any<AuditLogPaging>(),
@@ -195,6 +198,7 @@ public class AuditExportEndpointsTests
f.Target == null &&
f.Actor == null &&
f.CorrelationId == null &&
f.ExecutionId == null &&
f.FromUtc == null &&
f.ToUtc == null),
Arg.Any<AuditLogPaging>(),
@@ -222,6 +226,25 @@ public class AuditExportEndpointsTests
}
}
[Fact]
public async Task ExportEndpoint_UnparseableExecutionId_SilentlyDropped()
{
// Lax-parse contract: an unparseable executionId is dropped (no 400) —
// mirrors the correlationId parse.
var (client, repo, host) = await BuildHostAsync();
using (host)
{
var response = await client.GetAsync("/api/centralui/audit/export?executionId=not-a-guid");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
_ = await response.Content.ReadAsStringAsync();
await repo.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f => f.ExecutionId == null),
Arg.Any<AuditLogPaging>(),
Arg.Any<CancellationToken>());
}
}
/// <summary>
/// Test-only authentication handler that signs every request in as an Admin.
/// Admin is in <c>AuditExportRoles</c>, so the endpoint's AuditExport policy

View File

@@ -40,6 +40,7 @@ public class AuditDrilldownDrawerTests : BunitContext
string? responseSummary = null,
string? extra = null,
Guid? correlationId = null,
Guid? executionId = null,
string? errorMessage = null,
string? errorDetail = null,
string? target = "demo-target")
@@ -51,6 +52,7 @@ public class AuditDrilldownDrawerTests : BunitContext
Channel = channel,
Kind = kind,
CorrelationId = correlationId,
ExecutionId = executionId,
SourceSiteId = "plant-a",
SourceInstanceId = "boiler-3",
SourceScript = "OnAlarm.csx",
@@ -216,6 +218,46 @@ public class AuditDrilldownDrawerTests : BunitContext
Assert.Contains(corr.ToString(), nav.Uri);
}
[Fact]
public void Drawer_NullExecutionId_HidesViewThisExecutionButton()
{
var ev = MakeEvent(executionId: null);
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.DoesNotContain("data-test=\"view-this-execution\"", cut.Markup);
}
[Fact]
public void Drawer_NonNullExecutionId_ShowsViewThisExecutionButton()
{
var ev = MakeEvent(executionId: Guid.Parse("aaaaaaaa-1111-2222-3333-444444444444"));
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
Assert.Contains("data-test=\"view-this-execution\"", cut.Markup);
}
[Fact]
public void ViewThisExecution_Navigates_WithExecutionIdQueryString()
{
var exec = Guid.Parse("dddddddd-cccc-bbbb-aaaa-999999999999");
var ev = MakeEvent(executionId: exec);
var cut = Render<AuditDrilldownDrawer>(p => p
.Add(c => c.Event, ev)
.Add(c => c.IsOpen, true));
cut.Find("[data-test=\"view-this-execution\"]").Click();
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
Assert.Contains($"/audit/log?executionId={exec}", nav.Uri);
}
[Fact]
public async Task CopyAsCurl_InvokesClipboard_WithCurlString()
{

View File

@@ -61,6 +61,7 @@ public class AuditFilterBarTests : BunitContext
"data-test=\"filter-script\"",
"data-test=\"filter-target\"",
"data-test=\"filter-actor\"",
"data-test=\"filter-execution-id\"",
"data-test=\"filter-errors-only\"",
};
foreach (var marker in markers)
@@ -178,6 +179,42 @@ public class AuditFilterBarTests : BunitContext
Assert.Contains(AuditStatus.Failed, captured.Statuses);
}
[Fact]
public void Apply_WithPastedExecutionId_MapsThroughToFilter()
{
// The operator pastes a Guid into the Execution ID box; Apply must map it
// straight onto AuditLogQueryFilter.ExecutionId.
var executionId = Guid.Parse("99999999-8888-7777-6666-555555555555");
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
cut.Find("[data-test=\"filter-execution-id\"] input").Change(executionId.ToString());
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
Assert.Equal(executionId, captured!.ExecutionId);
}
[Fact]
public void Apply_WithBlankOrUnparseableExecutionId_LeavesFilterExecutionIdNull()
{
// Lax parsing: a blank box yields no constraint; garbage text likewise.
AuditLogQueryFilter? captured = null;
var cut = Render<AuditFilterBar>(p => p
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
// Blank — never typed into.
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.NotNull(captured);
Assert.Null(captured!.ExecutionId);
// Unparseable paste — still dropped, no error.
cut.Find("[data-test=\"filter-execution-id\"] input").Change("not-a-guid");
cut.Find("[data-test=\"filter-apply\"]").Click();
Assert.Null(captured!.ExecutionId);
}
[Fact]
public void TimeRange_LastHour_PopulatesFromUtc_ApproxOneHourAgo()
{

View File

@@ -22,7 +22,7 @@ public class AuditResultsGridTests : BunitContext
private readonly IAuditLogQueryService _service;
private readonly List<(AuditLogQueryFilter Filter, AuditLogPaging? Paging)> _calls = new();
private static AuditEvent MakeEvent(DateTime occurredAtUtc, AuditStatus status, AuditChannel channel = AuditChannel.ApiOutbound, AuditKind kind = AuditKind.ApiCall, string? site = "plant-a")
private static AuditEvent MakeEvent(DateTime occurredAtUtc, AuditStatus status, AuditChannel channel = AuditChannel.ApiOutbound, AuditKind kind = AuditKind.ApiCall, string? site = "plant-a", Guid? executionId = null)
=> new()
{
EventId = Guid.NewGuid(),
@@ -33,6 +33,7 @@ public class AuditResultsGridTests : BunitContext
SourceSiteId = site,
Target = "demo-target",
Actor = "tester",
ExecutionId = executionId,
DurationMs = 42,
HttpStatus = status == AuditStatus.Delivered ? 200 : 500,
ErrorMessage = status == AuditStatus.Failed ? "boom — unreachable" : null,
@@ -121,6 +122,49 @@ public class AuditResultsGridTests : BunitContext
Assert.Equal(target.EventId, captured!.EventId);
}
[Fact]
public void Render_IncludesExecutionIdColumn()
{
StubPage(new List<AuditEvent>
{
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
});
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// The ExecutionId column header is present alongside the spec columns.
Assert.Contains("data-test=\"col-header-ExecutionId\"", cut.Markup);
}
[Fact]
public void ExecutionId_NonNullRow_RendersShortMonospaceValue()
{
var executionId = Guid.Parse("abcdef01-2222-3333-4444-555555555555");
var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, executionId: executionId);
StubPage(new[] { row });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
var cell = cut.Find($"[data-test=\"execution-id-{row.EventId}\"]");
// Short form: first 8 hex digits of the "N" form.
Assert.Equal("abcdef01", cell.TextContent.Trim());
// Monospace presentation; full value retained in the title attribute.
Assert.Contains("font-monospace", cell.GetAttribute("class") ?? string.Empty);
Assert.Equal(executionId.ToString(), cell.GetAttribute("title"));
}
[Fact]
public void ExecutionId_NullRow_RendersBlankPlaceholder_NoExecutionIdCell()
{
var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, executionId: null);
StubPage(new[] { row });
var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));
// A null ExecutionId renders the em-dash placeholder, not a value cell.
Assert.Empty(cut.FindAll($"[data-test=\"execution-id-{row.EventId}\"]"));
}
[Fact]
public void Status_FailedRow_HasErrorBadgeClass()
{

View File

@@ -75,6 +75,20 @@ public class AuditLogPageExportUrlTests
Assert.Equal("Notification", query["channel"]);
}
[Fact]
public void BuildExportUrl_ExecutionIdSet_EmitsExecutionIdParam()
{
var exec = Guid.Parse("12121212-3434-5656-7878-909090909090");
var filter = new AuditLogQueryFilter(ExecutionId: exec);
var url = AuditLogPage.BuildExportUrl(filter);
Assert.StartsWith("/api/centralui/audit/export?", url);
var query = QueryHelpers.ParseQuery(new Uri("http://x" + url).Query);
Assert.Single(query);
Assert.Equal(exec.ToString(), query["executionId"]);
}
[Fact]
public void BuildExportUrl_MultiValueDimensions_EmitRepeatedParams()
{

View File

@@ -176,6 +176,44 @@ public class AuditLogPageScaffoldTests : BunitContext
});
}
[Fact]
public void NavigateWithExecutionIdParam_AppliesFilter_AndAutoLoads()
{
// The "View this execution" drill-in lands on /audit/log?executionId={id}.
// The page parses the Guid, builds an AuditLogQueryFilter with ExecutionId
// set, and auto-loads the grid.
var executionId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
_queryService = Substitute.For<IAuditLogQueryService>();
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
var cut = RenderAuditLogPageWithQuery($"executionId={executionId}", "Admin");
cut.WaitForAssertion(() =>
{
_queryService.Received().QueryAsync(
Arg.Is<AuditLogQueryFilter>(f => f.ExecutionId == executionId),
Arg.Any<AuditLogPaging?>(),
Arg.Any<CancellationToken>());
});
}
[Fact]
public void NavigateWithUnparseableExecutionIdParam_IsSilentlyDropped_NoAutoLoad()
{
_queryService = Substitute.For<IAuditLogQueryService>();
var cut = RenderAuditLogPageWithQuery("executionId=not-a-guid", "Admin");
// An unparseable executionId leaves ExecutionId null. With no other filter
// params present the page renders but does NOT call the query service.
cut.WaitForAssertion(() => Assert.Contains("Audit Log", cut.Markup));
_queryService.DidNotReceive().QueryAsync(
Arg.Any<AuditLogQueryFilter>(),
Arg.Any<AuditLogPaging?>(),
Arg.Any<CancellationToken>());
}
[Fact]
public void NavigateWithTargetParam_AppliesTargetFilter()
{

View File

@@ -17,6 +17,7 @@ public class AuditEventTests
var occurredAt = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
var ingestedAt = new DateTime(2026, 5, 20, 12, 0, 1, DateTimeKind.Utc);
var corrId = Guid.NewGuid();
var execId = Guid.NewGuid();
var evt = new AuditEvent
{
@@ -26,6 +27,7 @@ public class AuditEventTests
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
CorrelationId = corrId,
ExecutionId = execId,
SourceSiteId = "site-01",
SourceInstanceId = "inst-7",
SourceScript = "OnAlarm",
@@ -49,6 +51,7 @@ public class AuditEventTests
Assert.Equal(AuditChannel.ApiOutbound, evt.Channel);
Assert.Equal(AuditKind.ApiCall, evt.Kind);
Assert.Equal(corrId, evt.CorrelationId);
Assert.Equal(execId, evt.ExecutionId);
Assert.Equal("site-01", evt.SourceSiteId);
Assert.Equal("inst-7", evt.SourceInstanceId);
Assert.Equal("OnAlarm", evt.SourceScript);
@@ -77,6 +80,7 @@ public class AuditEventTests
Channel = AuditChannel.Notification,
Kind = AuditKind.NotifySend,
CorrelationId = null,
ExecutionId = null,
SourceSiteId = null,
SourceInstanceId = null,
SourceScript = null,
@@ -96,6 +100,7 @@ public class AuditEventTests
Assert.Null(evt.IngestedAtUtc);
Assert.Null(evt.CorrelationId);
Assert.Null(evt.ExecutionId);
Assert.Null(evt.SourceSiteId);
Assert.Null(evt.SourceInstanceId);
Assert.Null(evt.SourceScript);

View File

@@ -21,6 +21,21 @@ public class NotificationEntityTests
Assert.Equal("SiteA", n.SourceSiteId);
}
[Fact]
public void OriginExecutionId_DefaultsToNull_AndIsSettable()
{
// Audit Log #23: OriginExecutionId carries the originating script
// execution's id from the site so the dispatcher can echo it onto
// NotifyDeliver rows. Null for notifications submitted before the
// column existed; settable from the NotificationSubmit message.
var n = new Notification("id-1", NotificationType.Email, "ops-team", "subj", "body", "SiteA");
Assert.Null(n.OriginExecutionId);
var executionId = Guid.NewGuid();
n.OriginExecutionId = executionId;
Assert.Equal(executionId, n.OriginExecutionId);
}
[Fact]
public void Constructor_NullArguments_Throw()
{

View File

@@ -40,6 +40,47 @@ public class NotificationMessagesTests
Assert.Null(msg.SourceScript);
}
[Fact]
public void NotificationSubmit_OriginExecutionId_DefaultsToNull()
{
// Audit Log #23: OriginExecutionId is an additive trailing member — a
// submit built without it (old call sites / old serialized payloads)
// leaves the id null.
var msg = new NotificationSubmit(
"notif-3", "Operators", "Subject", "Body",
"site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow);
Assert.Null(msg.OriginExecutionId);
}
[Fact]
public void NotificationSubmit_OriginExecutionId_RoundTripsWhenSupplied()
{
var executionId = Guid.NewGuid();
var msg = new NotificationSubmit(
"notif-4", "Operators", "Subject", "Body",
"site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow, executionId);
Assert.Equal(executionId, msg.OriginExecutionId);
}
[Fact]
public void NotificationSubmit_OriginExecutionId_SurvivesJsonRoundTrip()
{
// The buffered S&F payload IS a serialized NotificationSubmit; the
// forwarder deserializes it, so OriginExecutionId must survive JSON.
var executionId = Guid.NewGuid();
var msg = new NotificationSubmit(
"notif-5", "Operators", "Subject", "Body",
"site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow, executionId);
var json = System.Text.Json.JsonSerializer.Serialize(msg);
var roundTripped = System.Text.Json.JsonSerializer.Deserialize<NotificationSubmit>(json);
Assert.NotNull(roundTripped);
Assert.Equal(executionId, roundTripped!.OriginExecutionId);
}
[Fact]
public void NotificationSubmit_ValueEquality_EqualWhenAllFieldsMatch()
{

View File

@@ -19,6 +19,7 @@ public class AuditEventDtoMapperTests
var occurredAt = new DateTime(2026, 5, 20, 10, 15, 30, 123, DateTimeKind.Utc);
var ingestedAt = new DateTime(2026, 5, 20, 10, 15, 31, 0, DateTimeKind.Utc);
var correlationId = Guid.NewGuid();
var executionId = Guid.NewGuid();
var eventId = Guid.NewGuid();
var original = new AuditEvent
@@ -29,6 +30,7 @@ public class AuditEventDtoMapperTests
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCallCached,
CorrelationId = correlationId,
ExecutionId = executionId,
SourceSiteId = "site-1",
SourceInstanceId = "Pump01",
SourceScript = "OnDemand",
@@ -54,6 +56,7 @@ public class AuditEventDtoMapperTests
Assert.Equal(original.Channel, roundTripped.Channel);
Assert.Equal(original.Kind, roundTripped.Kind);
Assert.Equal(original.CorrelationId, roundTripped.CorrelationId);
Assert.Equal(original.ExecutionId, roundTripped.ExecutionId);
Assert.Equal(original.SourceSiteId, roundTripped.SourceSiteId);
Assert.Equal(original.SourceInstanceId, roundTripped.SourceInstanceId);
Assert.Equal(original.SourceScript, roundTripped.SourceScript);
@@ -90,6 +93,7 @@ public class AuditEventDtoMapperTests
var dto = AuditEventDtoMapper.ToDto(evt);
Assert.Equal(string.Empty, dto.CorrelationId);
Assert.Equal(string.Empty, dto.ExecutionId);
Assert.Equal(string.Empty, dto.SourceSiteId);
Assert.Equal(string.Empty, dto.SourceInstanceId);
Assert.Equal(string.Empty, dto.SourceScript);
@@ -113,6 +117,7 @@ public class AuditEventDtoMapperTests
Kind = nameof(AuditKind.ApiCall),
Status = nameof(AuditStatus.Submitted),
CorrelationId = string.Empty,
ExecutionId = string.Empty,
SourceSiteId = string.Empty,
SourceInstanceId = string.Empty,
SourceScript = string.Empty,
@@ -128,6 +133,7 @@ public class AuditEventDtoMapperTests
var evt = AuditEventDtoMapper.FromDto(dto);
Assert.Null(evt.CorrelationId);
Assert.Null(evt.ExecutionId);
Assert.Null(evt.SourceSiteId);
Assert.Null(evt.SourceInstanceId);
Assert.Null(evt.SourceScript);

View File

@@ -74,8 +74,9 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
.Where(p => !p.IsShadowProperty())
.ToList();
// AuditEvent record exposes 21 init-only properties (alog.md §4).
Assert.Equal(21, properties.Count);
// AuditEvent record exposes 22 init-only properties (alog.md §4 plus the
// additive ExecutionId universal correlation column).
Assert.Equal(22, properties.Count);
}
[Fact]
@@ -90,11 +91,13 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
.ToList();
// Five reconciliation/query indexes from alog.md §4, plus the EventId unique
// index introduced alongside the composite PK (Bundle C).
// index introduced alongside the composite PK (Bundle C), plus the additive
// IX_AuditLog_Execution index supporting ExecutionId lookups.
var expected = new[]
{
"IX_AuditLog_Channel_Status_Occurred",
"IX_AuditLog_CorrelationId",
"IX_AuditLog_Execution",
"IX_AuditLog_OccurredAtUtc",
"IX_AuditLog_Site_Occurred",
"IX_AuditLog_Target_Occurred",
@@ -136,5 +139,9 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
var targetIdx = entity.GetIndexes()
.Single(i => i.GetDatabaseName() == "IX_AuditLog_Target_Occurred");
Assert.Equal("[Target] IS NOT NULL", targetIdx.GetFilter());
var executionIdx = entity.GetIndexes()
.Single(i => i.GetDatabaseName() == "IX_AuditLog_Execution");
Assert.Equal("[ExecutionId] IS NOT NULL", executionIdx.GetFilter());
}
}

View File

@@ -0,0 +1,70 @@
using Xunit;
namespace ScadaLink.ConfigurationDatabase.Tests.Migrations;
/// <summary>
/// Audit Log #23 (ExecutionId Task 5) integration test for the
/// <c>AddNotificationOriginExecutionId</c> migration: applies the EF migrations
/// to a freshly-created MSSQL test database on the running infra/mssql container
/// and asserts that the <c>Notifications</c> table carries the new
/// <c>OriginExecutionId</c> column as a nullable <c>uniqueidentifier</c>.
/// </summary>
/// <remarks>
/// Unlike <c>AuditLog</c>, the <c>Notifications</c> table is not partitioned, so
/// the column is a plain metadata-only <c>ALTER TABLE … ADD</c> with no index.
/// 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 migrations once at construction time.
/// </remarks>
public class AddNotificationOriginExecutionIdMigrationTests : IClassFixture<MsSqlMigrationFixture>
{
private readonly MsSqlMigrationFixture _fixture;
public AddNotificationOriginExecutionIdMigrationTests(MsSqlMigrationFixture fixture)
{
_fixture = fixture;
}
[SkippableFact]
public async Task AppliesMigration_AddsOriginExecutionIdColumn_ToNotifications()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var present = await ScalarAsync<int>(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginExecutionId' " +
"AND TABLE_SCHEMA = 'dbo';");
Assert.Equal(1, present);
}
[SkippableFact]
public async Task OriginExecutionIdColumn_IsNullableUniqueIdentifier()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var dataType = await ScalarAsync<string?>(
"SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginExecutionId';");
Assert.Equal("uniqueidentifier", dataType);
var isNullable = await ScalarAsync<string?>(
"SELECT IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginExecutionId';");
Assert.Equal("YES", isNullable);
}
// --- 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

@@ -247,6 +247,34 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Assert.All(rows, r => Assert.Equal(siteId, r.SourceSiteId));
}
[SkippableFact]
public async Task QueryAsync_FilterByExecutionId_ReturnsMatchingRows()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
await using var context = CreateContext();
var repo = new AuditLogRepository(context);
var executionId = Guid.NewGuid();
var t0 = new DateTime(2026, 5, 3, 12, 0, 0, DateTimeKind.Utc);
// Two rows share the ExecutionId; one carries a different ExecutionId and
// one leaves it null — both must be excluded by the single-value filter.
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, executionId: executionId));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), executionId: executionId));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), executionId: Guid.NewGuid()));
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(3), executionId: null));
var rows = await repo.QueryAsync(
new AuditLogQueryFilter(
SourceSiteIds: new[] { siteId },
ExecutionId: executionId),
new AuditLogPaging(PageSize: 10));
Assert.Equal(2, rows.Count);
Assert.All(rows, r => Assert.Equal(executionId, r.ExecutionId));
}
[SkippableFact]
public async Task QueryAsync_FilterByTimeRange()
{
@@ -725,7 +753,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
AuditChannel channel = AuditChannel.ApiOutbound,
AuditKind kind = AuditKind.ApiCall,
AuditStatus status = AuditStatus.Delivered,
string? errorMessage = null) =>
string? errorMessage = null,
Guid? executionId = null) =>
new()
{
EventId = Guid.NewGuid(),
@@ -735,5 +764,6 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
Status = status,
SourceSiteId = siteId,
ErrorMessage = errorMessage,
ExecutionId = executionId,
};
}

View File

@@ -268,6 +268,7 @@ public class NotificationOutboxConfigurationTests : IDisposable
var lastAttemptAt = new DateTimeOffset(2026, 5, 19, 8, 1, 0, TimeSpan.Zero);
var nextAttemptAt = new DateTimeOffset(2026, 5, 19, 8, 2, 0, TimeSpan.Zero);
var deliveredAt = new DateTimeOffset(2026, 5, 19, 8, 3, 0, TimeSpan.Zero);
var originExecutionId = Guid.NewGuid();
var notification = new Notification(id, NotificationType.Email, "Ops List",
"High Tank Level", "Tank 4 exceeded the high level threshold.", "site-north")
@@ -279,6 +280,7 @@ public class NotificationOutboxConfigurationTests : IDisposable
ResolvedTargets = "ops@example.test;duty@example.test",
SourceInstanceId = "instance-42",
SourceScript = "TankLevelAlarm",
OriginExecutionId = originExecutionId,
SiteEnqueuedAt = siteEnqueuedAt,
CreatedAt = createdAt,
LastAttemptAt = lastAttemptAt,
@@ -311,6 +313,27 @@ public class NotificationOutboxConfigurationTests : IDisposable
Assert.Equal(lastAttemptAt, loaded.LastAttemptAt);
Assert.Equal(nextAttemptAt, loaded.NextAttemptAt);
Assert.Equal(deliveredAt, loaded.DeliveredAt);
Assert.Equal(originExecutionId, loaded.OriginExecutionId);
}
[Fact]
public async Task Notification_NullOriginExecutionId_RoundTripsAsNull()
{
// Audit Log #23: OriginExecutionId is an additive nullable column —
// notifications raised outside a script execution (or submitted before
// the column existed) persist and reload it as null.
var id = Guid.NewGuid().ToString();
var notification = new Notification(id, NotificationType.Email, "Ops List",
"Subject", "Body", "site-north");
_context.Notifications.Add(notification);
await _context.SaveChangesAsync();
_context.ChangeTracker.Clear();
var loaded = await _context.Notifications.FindAsync(id);
Assert.NotNull(loaded);
Assert.Null(loaded!.OriginExecutionId);
}
[Fact]

View File

@@ -101,15 +101,27 @@ public class DatabaseGatewayTests
var gateway = new DatabaseGateway(_repository, NullLogger<DatabaseGateway>.Instance, storeAndForward: sf);
// Audit Log #23 (ExecutionId Task 4): a known execution id / source
// script so the gateway -> EnqueueAsync hop can be asserted below.
var executionId = Guid.NewGuid();
const string sourceScript = "ScriptActor:WriteAudit";
await gateway.CachedWriteAsync("testDb", "INSERT INTO t VALUES (@v)",
new Dictionary<string, object?> { ["v"] = 1 });
new Dictionary<string, object?> { ["v"] = 1 },
executionId: executionId, sourceScript: sourceScript);
var depth = await storage.GetBufferDepthByCategoryAsync();
Assert.Equal(1, depth[ScadaLink.Commons.Types.Enums.StoreAndForwardCategory.CachedDbWrite]);
var (maxRetries, retryIntervalMs) = ReadBufferedRetrySettings(connStr);
Assert.Equal(5, maxRetries);
Assert.Equal((long)TimeSpan.FromSeconds(12).TotalMilliseconds, retryIntervalMs);
var buffered = ReadBufferedRetrySettings(connStr);
Assert.Equal(5, buffered.MaxRetries);
Assert.Equal((long)TimeSpan.FromSeconds(12).TotalMilliseconds, buffered.RetryIntervalMs);
// ExecutionId Task 4: the gateway must forward executionId / sourceScript
// into EnqueueAsync, and the S&F layer must persist them on the
// sf_messages row so the retry loop can stamp the right provenance.
Assert.Equal(executionId, buffered.ExecutionId);
Assert.Equal(sourceScript, buffered.SourceScript);
}
[Fact]
@@ -148,21 +160,27 @@ public class DatabaseGatewayTests
await gateway.CachedWriteAsync("testDb", "INSERT INTO t VALUES (1)");
var (maxRetries, _) = ReadBufferedRetrySettings(connStr);
var (maxRetries, _, _, _) = ReadBufferedRetrySettings(connStr);
// Must be the bounded S&F default, never 0 — a stored 0 would mean retry-forever.
Assert.Equal(99, maxRetries);
Assert.NotEqual(0, maxRetries);
}
private static (int MaxRetries, long RetryIntervalMs) ReadBufferedRetrySettings(string connStr)
private static (int MaxRetries, long RetryIntervalMs, Guid? ExecutionId, string? SourceScript)
ReadBufferedRetrySettings(string connStr)
{
using var conn = new Microsoft.Data.Sqlite.SqliteConnection(connStr);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT max_retries, retry_interval_ms FROM sf_messages";
cmd.CommandText =
"SELECT max_retries, retry_interval_ms, execution_id, source_script FROM sf_messages";
using var reader = cmd.ExecuteReader();
Assert.True(reader.Read(), "expected exactly one buffered message");
var result = (reader.GetInt32(0), reader.GetInt64(1));
var result = (
reader.GetInt32(0),
reader.GetInt64(1),
reader.IsDBNull(2) ? (Guid?)null : Guid.Parse(reader.GetString(2)),
reader.IsDBNull(3) ? null : reader.GetString(3));
Assert.False(reader.Read(), "expected exactly one buffered message");
return result;
}

View File

@@ -371,26 +371,45 @@ public class ExternalSystemClientTests
_httpClientFactory, _repository, NullLogger<ExternalSystemClient>.Instance,
storeAndForward: sf);
var result = await client.CachedCallAsync("TestAPI", "postData");
// Audit Log #23 (ExecutionId Task 4): a known execution id / source
// script so the gateway -> EnqueueAsync hop can be asserted below.
var executionId = Guid.NewGuid();
const string sourceScript = "ScriptActor:CheckPressure";
var result = await client.CachedCallAsync(
"TestAPI", "postData",
executionId: executionId, sourceScript: sourceScript);
Assert.True(result.WasBuffered);
var depth = await storage.GetBufferDepthByCategoryAsync();
Assert.Equal(1, depth[ScadaLink.Commons.Types.Enums.StoreAndForwardCategory.ExternalSystem]);
var (maxRetries, retryIntervalMs) = ReadBufferedRetrySettings(connStr);
Assert.Equal(7, maxRetries);
Assert.Equal((long)TimeSpan.FromSeconds(42).TotalMilliseconds, retryIntervalMs);
var buffered = ReadBufferedRetrySettings(connStr);
Assert.Equal(7, buffered.MaxRetries);
Assert.Equal((long)TimeSpan.FromSeconds(42).TotalMilliseconds, buffered.RetryIntervalMs);
// ExecutionId Task 4: the gateway must forward executionId / sourceScript
// into EnqueueAsync, and the S&F layer must persist them on the
// sf_messages row so the retry loop can stamp the right provenance.
Assert.Equal(executionId, buffered.ExecutionId);
Assert.Equal(sourceScript, buffered.SourceScript);
}
private static (int MaxRetries, long RetryIntervalMs) ReadBufferedRetrySettings(string connStr)
private static (int MaxRetries, long RetryIntervalMs, Guid? ExecutionId, string? SourceScript)
ReadBufferedRetrySettings(string connStr)
{
using var conn = new SqliteConnection(connStr);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT max_retries, retry_interval_ms FROM sf_messages";
cmd.CommandText =
"SELECT max_retries, retry_interval_ms, execution_id, source_script FROM sf_messages";
using var reader = cmd.ExecuteReader();
Assert.True(reader.Read(), "expected exactly one buffered message");
var result = (reader.GetInt32(0), reader.GetInt64(1));
var result = (
reader.GetInt32(0),
reader.GetInt64(1),
reader.IsDBNull(2) ? (Guid?)null : Guid.Parse(reader.GetString(2)),
reader.IsDBNull(3) ? null : reader.GetString(3));
Assert.False(reader.Read(), "expected exactly one buffered message");
return result;
}
@@ -436,7 +455,7 @@ public class ExternalSystemClientTests
await client.CachedCallAsync("TestAPI", "postData");
var (maxRetries, _) = ReadBufferedRetrySettings(connStr);
var (maxRetries, _, _, _) = ReadBufferedRetrySettings(connStr);
// Must be the bounded S&F default, never 0 — a stored 0 would mean retry-forever.
Assert.Equal(99, maxRetries);
Assert.NotEqual(0, maxRetries);

View File

@@ -351,12 +351,14 @@ public class AuditWriteMiddlewareTests
}
// ---------------------------------------------------------------------
// Correlation id — Audit Log #23: each inbound row carries a fresh
// per-request correlation id so inbound rows are correlatable.
// Execution id — Audit Log #23: each inbound row carries a fresh
// per-request execution id so inbound rows are correlatable. The inbound
// row's CorrelationId stays null — CorrelationId is purely the
// per-operation-lifecycle id and an inbound request is a one-shot.
// ---------------------------------------------------------------------
[Fact]
public async Task InboundRow_CarriesNonNull_CorrelationId()
public async Task InboundRow_CarriesNonNull_ExecutionId_And_NullCorrelationId()
{
var writer = new RecordingAuditWriter();
var ctx = BuildContext();
@@ -369,12 +371,15 @@ public class AuditWriteMiddlewareTests
await mw.InvokeAsync(ctx);
var evt = Assert.Single(writer.Events);
Assert.NotNull(evt.CorrelationId);
Assert.NotEqual(Guid.Empty, evt.CorrelationId!.Value);
Assert.NotNull(evt.ExecutionId);
Assert.NotEqual(Guid.Empty, evt.ExecutionId!.Value);
// CorrelationId is the per-operation-lifecycle id; an inbound request
// is a one-shot with no multi-row operation to correlate.
Assert.Null(evt.CorrelationId);
}
[Fact]
public async Task SeparateRequests_GetDistinct_CorrelationIds()
public async Task SeparateRequests_GetDistinct_ExecutionIds()
{
var writer = new RecordingAuditWriter();
var mw = CreateMiddleware(hc =>
@@ -387,7 +392,7 @@ public class AuditWriteMiddlewareTests
await mw.InvokeAsync(BuildContext());
Assert.Equal(2, writer.Events.Count);
Assert.NotEqual(writer.Events[0].CorrelationId, writer.Events[1].CorrelationId);
Assert.NotEqual(writer.Events[0].ExecutionId, writer.Events[1].ExecutionId);
}
[Fact]

View File

@@ -428,6 +428,38 @@ public class AuditEndpointsTests
Assert.Null(filter.Statuses);
}
[Fact]
public void ParseFilter_ExecutionId_ParsesIntoSingleValueGuid()
{
// executionId is a single-value Guid? filter — mirrors correlationId.
var executionId = Guid.NewGuid();
var query = new Microsoft.AspNetCore.Http.QueryCollection(
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
["executionId"] = executionId.ToString(),
});
var filter = AuditEndpoints.ParseFilter(query);
Assert.Equal(executionId, filter.ExecutionId);
}
[Fact]
public void ParseFilter_UnparseableExecutionId_IsDroppedSilently()
{
// Lax-parse contract: an unparseable executionId is dropped (no 400) —
// mirrors the correlationId parse.
var query = new Microsoft.AspNetCore.Http.QueryCollection(
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
{
["executionId"] = "not-a-guid",
});
var filter = AuditEndpoints.ParseFilter(query);
Assert.Null(filter.ExecutionId);
}
[Fact]
public async Task Query_RepeatedChannelParams_ReachRepositoryAsMultiValueFilter()
{

View File

@@ -94,7 +94,8 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit
private static Notification MakeNotification(
Guid? notificationId = null,
string sourceSite = "site-1",
int retryCount = 0)
int retryCount = 0,
Guid? originExecutionId = null)
{
return new Notification(
(notificationId ?? Guid.NewGuid()).ToString("D"),
@@ -108,6 +109,7 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit
CreatedAt = DateTimeOffset.UtcNow,
SourceInstanceId = "instance-42",
SourceScript = "AlarmScript",
OriginExecutionId = originExecutionId,
};
}
@@ -162,6 +164,49 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit
});
}
[Fact]
public void Attempt_CarriesOriginExecutionId_AsExecutionId()
{
// Audit Log #23: the Attempted NotifyDeliver row must echo the
// notification's OriginExecutionId so all rows for one run share an id.
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var executionId = Guid.NewGuid();
var notification = MakeNotification(originExecutionId: executionId);
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
var attempted = EventsByStatus(AuditStatus.Attempted);
Assert.Single(attempted);
Assert.Equal(executionId, attempted[0].ExecutionId);
});
}
[Fact]
public void Attempt_NullOriginExecutionId_HasNullExecutionId()
{
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var notification = MakeNotification(originExecutionId: null);
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
var attempted = EventsByStatus(AuditStatus.Attempted);
Assert.Single(attempted);
Assert.Null(attempted[0].ExecutionId);
});
}
[Fact]
public void Attempt_TransientFailure_EmitsEvent_StatusAttempted_ErrorMessageSet()
{

View File

@@ -39,7 +39,8 @@ public class NotificationOutboxActorIngestTests : TestKit
NullLogger<NotificationOutboxActor>.Instance)));
}
private static NotificationSubmit MakeSubmit(string? notificationId = null)
private static NotificationSubmit MakeSubmit(
string? notificationId = null, Guid? originExecutionId = null)
{
return new NotificationSubmit(
NotificationId: notificationId ?? Guid.NewGuid().ToString(),
@@ -49,7 +50,8 @@ public class NotificationOutboxActorIngestTests : TestKit
SourceSiteId: "site-1",
SourceInstanceId: "instance-42",
SourceScript: "AlarmScript",
SiteEnqueuedAt: new DateTimeOffset(2026, 5, 19, 8, 30, 0, TimeSpan.Zero));
SiteEnqueuedAt: new DateTimeOffset(2026, 5, 19, 8, 30, 0, TimeSpan.Zero),
OriginExecutionId: originExecutionId);
}
[Fact]
@@ -83,6 +85,42 @@ public class NotificationOutboxActorIngestTests : TestKit
Arg.Any<CancellationToken>());
}
[Fact]
public void NotificationSubmit_CopiesOriginExecutionId_OntoPersistedNotification()
{
// Audit Log #23: the originating script execution's id rides on the
// NotificationSubmit and must be persisted on the Notification row so
// the dispatcher can later echo it onto NotifyDeliver audit rows.
_repository.InsertIfNotExistsAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>())
.Returns(true);
var executionId = Guid.NewGuid();
var submit = MakeSubmit(originExecutionId: executionId);
var actor = CreateActor();
actor.Tell(submit, TestActor);
ExpectMsg<NotificationSubmitAck>();
_repository.Received(1).InsertIfNotExistsAsync(
Arg.Is<Notification>(n => n.OriginExecutionId == executionId),
Arg.Any<CancellationToken>());
}
[Fact]
public void NotificationSubmit_NullOriginExecutionId_PersistsNull()
{
_repository.InsertIfNotExistsAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>())
.Returns(true);
var submit = MakeSubmit(originExecutionId: null);
var actor = CreateActor();
actor.Tell(submit, TestActor);
ExpectMsg<NotificationSubmitAck>();
_repository.Received(1).InsertIfNotExistsAsync(
Arg.Is<Notification>(n => n.OriginExecutionId == null),
Arg.Any<CancellationToken>());
}
[Fact]
public void DuplicateSubmit_RepositoryReturnsFalse_StillAcksAccepted()
{

View File

@@ -87,7 +87,8 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit
private static Notification MakeNotification(
NotificationStatus status = NotificationStatus.Pending,
int retryCount = 0,
Guid? notificationId = null)
Guid? notificationId = null,
Guid? originExecutionId = null)
{
return new Notification(
(notificationId ?? Guid.NewGuid()).ToString("D"),
@@ -100,6 +101,7 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit
Status = status,
RetryCount = retryCount,
CreatedAt = DateTimeOffset.UtcNow,
OriginExecutionId = originExecutionId,
};
}
@@ -145,6 +147,50 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit
});
}
[Fact]
public void Terminal_Delivered_CarriesOriginExecutionId_AsExecutionId()
{
// Audit Log #23: the terminal NotifyDeliver row must echo the
// notification's OriginExecutionId so it shares the per-run id with
// the site-emitted NotifySend row.
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var executionId = Guid.NewGuid();
var notification = MakeNotification(originExecutionId: executionId);
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
var delivered = EventsByStatus(AuditStatus.Delivered);
Assert.Single(delivered);
Assert.Equal(executionId, delivered[0].ExecutionId);
});
}
[Fact]
public void Terminal_Delivered_NullOriginExecutionId_HasNullExecutionId()
{
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var notification = MakeNotification(originExecutionId: null);
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
var delivered = EventsByStatus(AuditStatus.Delivered);
Assert.Single(delivered);
Assert.Null(delivered[0].ExecutionId);
});
}
[Fact]
public void Terminal_Parked_OnPermanentFailure_EmitsEvent_StatusParked()
{
@@ -166,6 +212,31 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit
});
}
[Fact]
public void Terminal_Parked_CarriesOriginExecutionId_AsExecutionId()
{
// Audit Log #23: the Parked terminal NotifyDeliver row flows through the
// same BuildNotifyDeliverEvent path as the Delivered row, so it must
// likewise echo the notification's OriginExecutionId — sharing the
// per-run id with the site-emitted NotifySend row.
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
var executionId = Guid.NewGuid();
var notification = MakeNotification(originExecutionId: executionId);
_outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new[] { notification });
var adapter = new StubAdapter(() => DeliveryOutcome.Permanent("invalid recipient address"));
var actor = CreateActor([adapter]);
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() =>
{
var parked = EventsByStatus(AuditStatus.Parked);
Assert.Single(parked);
Assert.Equal(executionId, parked[0].ExecutionId);
});
}
[Fact]
public void Terminal_Parked_OnTransientReachingMaxRetries_EmitsEvent_StatusParked()
{

View File

@@ -39,6 +39,12 @@ public class DatabaseCachedWriteEmissionTests
private const string InstanceName = "Plant.Pump42";
private const string SourceScript = "ScriptActor:WriteAudit";
/// <summary>
/// Audit Log #23: a fixed per-execution id so the cached-row tests can
/// assert <see cref="AuditEvent.ExecutionId"/> against a known value.
/// </summary>
private static readonly Guid TestExecutionId = Guid.NewGuid();
private static ScriptRuntimeContext.DatabaseHelper CreateHelper(
IDatabaseGateway gateway,
ICachedCallTelemetryForwarder? forwarder)
@@ -47,9 +53,10 @@ public class DatabaseCachedWriteEmissionTests
gateway,
InstanceName,
NullLogger.Instance,
// Audit Log #23: execution-wide correlation id. Cached rows keep
// CorrelationId = TrackedOperationId, so any value works here.
Guid.NewGuid(),
// Audit Log #23: the per-execution id stamped into ExecutionId on
// every script-side row. Cached rows keep CorrelationId =
// TrackedOperationId (the per-operation lifecycle id).
TestExecutionId,
siteId: SiteId,
sourceScript: SourceScript,
cachedForwarder: forwarder);
@@ -65,7 +72,8 @@ public class DatabaseCachedWriteEmissionTests
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder();
@@ -79,7 +87,10 @@ public class DatabaseCachedWriteEmissionTests
Assert.Equal(AuditKind.CachedSubmit, packet.Audit.Kind);
Assert.Equal(AuditStatus.Submitted, packet.Audit.Status);
Assert.Equal("myDb", packet.Audit.Target);
// CorrelationId is the per-operation lifecycle id (TrackedOperationId);
// ExecutionId is the per-execution id from the runtime context.
Assert.Equal(trackedId.Value, packet.Audit.CorrelationId);
Assert.Equal(TestExecutionId, packet.Audit.ExecutionId);
Assert.Equal(trackedId, packet.Operational.TrackedOperationId);
Assert.Equal("DbOutbound", packet.Operational.Channel);
@@ -100,7 +111,8 @@ public class DatabaseCachedWriteEmissionTests
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder();
@@ -124,7 +136,8 @@ public class DatabaseCachedWriteEmissionTests
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder();
@@ -137,7 +150,48 @@ public class DatabaseCachedWriteEmissionTests
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
trackedId),
trackedId,
It.IsAny<Guid?>(), It.IsAny<string?>()),
Times.Once);
}
/// <summary>
/// Audit Log #23 (ExecutionId Task 4): the helper → gateway hop of the
/// threading chain. The cached-write helper must forward the runtime
/// context's <c>ExecutionId</c> and <c>SourceScript</c> verbatim into
/// <see cref="IDatabaseGateway.CachedWriteAsync"/> — so the buffered retry
/// loop later stamps the right provenance onto its audit rows. This
/// asserts the exact id/script (not <c>It.IsAny</c>), so a regression that
/// dropped the threading would fail here.
/// </summary>
[Fact]
public async Task CachedWrite_ThreadsExecutionIdAndSourceScript_IntoGateway()
{
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?>(),
It.IsAny<Guid?>(), It.IsAny<string?>()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder();
var helper = CreateHelper(gateway.Object, forwarder);
await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)");
// The known TestExecutionId and SourceScript must reach the gateway
// unchanged — these are what the S&F retry loop persists and replays.
gateway.Verify(g => g.CachedWriteAsync(
"myDb", "INSERT INTO t VALUES (1)",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.Is<Guid?>(id => id == TestExecutionId),
It.Is<string?>(s => s == SourceScript)),
Times.Once);
}
@@ -151,7 +205,8 @@ public class DatabaseCachedWriteEmissionTests
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>()))
.Returns(Task.CompletedTask);
var forwarder = new CapturingForwarder
{
@@ -167,7 +222,8 @@ public class DatabaseCachedWriteEmissionTests
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
trackedId),
trackedId,
It.IsAny<Guid?>(), It.IsAny<string?>()),
Times.Once);
}
}

View File

@@ -49,27 +49,27 @@ public class DatabaseSyncEmissionTests
private const string ConnectionName = "machineData";
/// <summary>
/// Audit Log #23: a fixed execution-wide correlation id used by the
/// default <see cref="CreateHelper(IDatabaseGateway, IAuditWriter?)"/>
/// Audit Log #23: a fixed per-execution id used by the default
/// <see cref="CreateHelper(IDatabaseGateway, IAuditWriter?)"/>
/// overload so assertions can compare against a known value.
/// </summary>
private static readonly Guid TestCorrelationId = Guid.NewGuid();
private static readonly Guid TestExecutionId = Guid.NewGuid();
private static ScriptRuntimeContext.DatabaseHelper CreateHelper(
IDatabaseGateway gateway,
IAuditWriter? auditWriter)
=> CreateHelper(gateway, auditWriter, TestCorrelationId);
=> CreateHelper(gateway, auditWriter, TestExecutionId);
private static ScriptRuntimeContext.DatabaseHelper CreateHelper(
IDatabaseGateway gateway,
IAuditWriter? auditWriter,
Guid correlationId)
Guid executionId)
{
return new ScriptRuntimeContext.DatabaseHelper(
gateway,
InstanceName,
NullLogger.Instance,
correlationId,
executionId,
auditWriter: auditWriter,
siteId: SiteId,
sourceScript: SourceScript,
@@ -282,14 +282,16 @@ public class DatabaseSyncEmissionTests
Assert.Equal(SourceScript, evt.SourceScript);
// Outbound channel: Actor carries the calling script identity.
Assert.Equal(SourceScript, evt.Actor);
// Audit Log #23: the sync DbWrite row now carries the execution-wide
// correlation id the helper was constructed with.
Assert.Equal(TestCorrelationId, evt.CorrelationId);
// Audit Log #23: the sync DbWrite row carries the per-execution id the
// helper was constructed with in ExecutionId. CorrelationId is null —
// a sync one-shot call has no operation lifecycle.
Assert.Equal(TestExecutionId, evt.ExecutionId);
Assert.Null(evt.CorrelationId);
Assert.NotEqual(Guid.Empty, evt.EventId);
}
[Fact]
public async Task SyncDbWrite_StampsExecutionCorrelationId()
public async Task SyncDbWrite_StampsExecutionId_AndNullCorrelationId()
{
using var keepAlive = new SqliteConnection("Data Source=kc;Mode=Memory;Cache=Shared");
var inner = NewInMemoryDb(out var _);
@@ -298,16 +300,18 @@ public class DatabaseSyncEmissionTests
.Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny<CancellationToken>()))
.ReturnsAsync(inner);
var writer = new CapturingAuditWriter();
var correlationId = Guid.NewGuid();
var executionId = Guid.NewGuid();
var helper = CreateHelper(gateway.Object, writer, correlationId);
var helper = CreateHelper(gateway.Object, writer, executionId);
await using var conn = await helper.Connection(ConnectionName);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "INSERT INTO t (id, name) VALUES (7, 'eta')";
await cmd.ExecuteNonQueryAsync();
var evt = Assert.Single(writer.Events);
Assert.Equal(correlationId, evt.CorrelationId);
Assert.Equal(executionId, evt.ExecutionId);
// Sync one-shot call: CorrelationId is null (no operation lifecycle).
Assert.Null(evt.CorrelationId);
}
[Fact]

View File

@@ -16,13 +16,15 @@ namespace ScadaLink.SiteRuntime.Tests.Scripts;
/// <list type="bullet">
/// <item><description>
/// The <c>?? Guid.NewGuid()</c> fallback in the <see cref="ScriptRuntimeContext"/>
/// ctor: when no audit correlation id is supplied (tag-change / timer-triggered
/// ctor: when no execution id is supplied (tag-change / timer-triggered
/// executions) a fresh, non-empty id is minted and stamped on the emitted rows.
/// </description></item>
/// <item><description>
/// The execution-wide contract: an <c>ExternalSystem.Call</c> and a sync
/// <c>Database</c> write performed through ONE context share a single
/// <see cref="AuditEvent.CorrelationId"/>.
/// <see cref="AuditEvent.ExecutionId"/>. The per-operation
/// <see cref="AuditEvent.CorrelationId"/> stays null for these sync one-shot
/// calls — a sync call has no operation lifecycle.
/// </description></item>
/// </list>
/// </summary>
@@ -53,14 +55,14 @@ public class ExecutionCorrelationContextTests
/// system client, database gateway and audit writer the cross-helper test
/// needs. The actor refs are <see cref="ActorRefs.Nobody"/> — the
/// integration helpers (ExternalSystem / Database) never touch them — and
/// <paramref name="auditCorrelationId"/> defaults to null so the ctor's
/// <paramref name="executionId"/> defaults to null so the ctor's
/// <c>?? Guid.NewGuid()</c> fallback is exercised unless a test supplies one.
/// </summary>
private static ScriptRuntimeContext CreateContext(
IExternalSystemClient? externalSystemClient,
IDatabaseGateway? databaseGateway,
IAuditWriter? auditWriter,
Guid? auditCorrelationId = null)
Guid? executionId = null)
{
var compilationService = new ScriptCompilationService(
NullLogger<ScriptCompilationService>.Instance);
@@ -85,7 +87,7 @@ public class ExecutionCorrelationContextTests
auditWriter: auditWriter,
operationTrackingStore: null,
cachedForwarder: null,
auditCorrelationId: auditCorrelationId);
executionId: executionId);
}
/// <summary>
@@ -113,9 +115,9 @@ public class ExecutionCorrelationContextTests
}
[Fact]
public async Task NoCorrelationIdSupplied_SyncCall_StampsFreshNonEmptyCorrelationId()
public async Task NoExecutionIdSupplied_SyncCall_StampsFreshNonEmptyExecutionId()
{
// No auditCorrelationId argument — the ScriptRuntimeContext ctor's
// No executionId argument — the ScriptRuntimeContext ctor's
// `?? Guid.NewGuid()` fallback must mint one (this is the unsupplied-id
// branch every other audit test bypasses by passing an explicit id).
var client = new Mock<IExternalSystemClient>();
@@ -128,17 +130,19 @@ public class ExecutionCorrelationContextTests
await context.ExternalSystem.Call("ERP", "GetOrder");
var evt = Assert.Single(writer.Events);
Assert.NotNull(evt.CorrelationId);
Assert.NotEqual(Guid.Empty, evt.CorrelationId!.Value);
Assert.NotNull(evt.ExecutionId);
Assert.NotEqual(Guid.Empty, evt.ExecutionId!.Value);
// A sync one-shot call has no operation lifecycle — CorrelationId is null.
Assert.Null(evt.CorrelationId);
}
[Fact]
public async Task SameContext_ApiCallAndDbWrite_ShareTheSameCorrelationId()
public async Task SameContext_ApiCallAndDbWrite_ShareTheSameExecutionId()
{
// The execution-wide contract: an ExternalSystem.Call AND a sync
// Database write performed through ONE ScriptRuntimeContext must both
// carry the same execution correlation id, so an audit reader can tie
// every trust-boundary action from one script run together.
// carry the same ExecutionId, so an audit reader can tie every
// trust-boundary action from one script run together.
using var keepAlive = new SqliteConnection("Data Source=ecc;Mode=Memory;Cache=Shared");
var innerDb = NewInMemoryDb(out var _);
@@ -170,10 +174,13 @@ public class ExecutionCorrelationContextTests
var apiRow = Assert.Single(writer.Events, e => e.Channel == AuditChannel.ApiOutbound);
var dbRow = Assert.Single(writer.Events, e => e.Channel == AuditChannel.DbOutbound);
Assert.NotNull(apiRow.CorrelationId);
Assert.NotEqual(Guid.Empty, apiRow.CorrelationId!.Value);
Assert.NotNull(apiRow.ExecutionId);
Assert.NotEqual(Guid.Empty, apiRow.ExecutionId!.Value);
// The ApiCall row and the DbWrite row, emitted by two different helpers
// resolved off one context, carry the identical execution correlation id.
Assert.Equal(apiRow.CorrelationId, dbRow.CorrelationId);
// resolved off one context, carry the identical ExecutionId.
Assert.Equal(apiRow.ExecutionId, dbRow.ExecutionId);
// Both are sync one-shot calls — neither carries a CorrelationId.
Assert.Null(apiRow.CorrelationId);
Assert.Null(dbRow.CorrelationId);
}
}

View File

@@ -41,6 +41,12 @@ public class ExternalSystemCachedCallEmissionTests
private const string InstanceName = "Plant.Pump42";
private const string SourceScript = "ScriptActor:CheckPressure";
/// <summary>
/// Audit Log #23: a fixed per-execution id so the cached-row tests can
/// assert <see cref="AuditEvent.ExecutionId"/> against a known value.
/// </summary>
private static readonly Guid TestExecutionId = Guid.NewGuid();
private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper(
IExternalSystemClient client,
ICachedCallTelemetryForwarder? forwarder)
@@ -49,9 +55,10 @@ public class ExternalSystemCachedCallEmissionTests
client,
InstanceName,
NullLogger.Instance,
// Audit Log #23: execution-wide correlation id. Cached rows keep
// CorrelationId = TrackedOperationId, so any value works here.
Guid.NewGuid(),
// Audit Log #23: the per-execution id stamped into ExecutionId on
// every script-side row. Cached rows keep CorrelationId =
// TrackedOperationId (the per-operation lifecycle id).
TestExecutionId,
auditWriter: null,
siteId: SiteId,
sourceScript: SourceScript,
@@ -68,7 +75,8 @@ public class ExternalSystemCachedCallEmissionTests
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
@@ -83,7 +91,10 @@ public class ExternalSystemCachedCallEmissionTests
Assert.Equal(AuditKind.CachedSubmit, packet.Audit.Kind);
Assert.Equal(AuditStatus.Submitted, packet.Audit.Status);
Assert.Equal("ERP.GetOrder", packet.Audit.Target);
// CorrelationId is the per-operation lifecycle id (TrackedOperationId);
// ExecutionId is the per-execution id from the runtime context.
Assert.Equal(trackedId.Value, packet.Audit.CorrelationId);
Assert.Equal(TestExecutionId, packet.Audit.ExecutionId);
Assert.Equal(AuditForwardState.Pending, packet.Audit.ForwardState);
// Operational mirror — same id, Submitted, RetryCount 0, not terminal.
@@ -107,7 +118,8 @@ public class ExternalSystemCachedCallEmissionTests
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>()))
.ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false));
var forwarder = new CapturingForwarder();
@@ -143,7 +155,8 @@ public class ExternalSystemCachedCallEmissionTests
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
@@ -162,14 +175,56 @@ public class ExternalSystemCachedCallEmissionTests
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
id1),
id1,
It.IsAny<Guid?>(), It.IsAny<string?>()),
Times.Once);
client.Verify(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
id2),
id2,
It.IsAny<Guid?>(), It.IsAny<string?>()),
Times.Once);
}
/// <summary>
/// Audit Log #23 (ExecutionId Task 4): the helper → gateway hop of the
/// threading chain. The cached-call helper must forward the runtime
/// context's <c>ExecutionId</c> and <c>SourceScript</c> verbatim into
/// <see cref="IExternalSystemClient.CachedCallAsync"/> — so the buffered
/// retry loop later stamps the right provenance onto its audit rows.
/// This asserts the exact id/script (not <c>It.IsAny</c>), so a regression
/// that dropped the threading would fail here.
/// </summary>
[Fact]
public async Task CachedCall_ThreadsExecutionIdAndSourceScript_IntoClient()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
var helper = CreateHelper(client.Object, forwarder);
await helper.CachedCall("ERP", "GetOrder");
// The known TestExecutionId and SourceScript must reach the client
// unchanged — these are what the S&F retry loop persists and replays.
client.Verify(c => c.CachedCallAsync(
"ERP", "GetOrder",
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>(),
It.Is<Guid?>(id => id == TestExecutionId),
It.Is<string?>(s => s == SourceScript)),
Times.Once);
}
@@ -183,7 +238,8 @@ public class ExternalSystemCachedCallEmissionTests
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder
{
@@ -202,7 +258,8 @@ public class ExternalSystemCachedCallEmissionTests
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
trackedId),
trackedId,
It.IsAny<Guid?>(), It.IsAny<string?>()),
Times.Once);
}
@@ -216,7 +273,8 @@ public class ExternalSystemCachedCallEmissionTests
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var forwarder = new CapturingForwarder();
@@ -242,7 +300,8 @@ public class ExternalSystemCachedCallEmissionTests
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
It.IsAny<string?>(),
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>()))
.ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true));
var helper = CreateHelper(client.Object, forwarder: null);
@@ -254,7 +313,8 @@ public class ExternalSystemCachedCallEmissionTests
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
trackedId),
trackedId,
It.IsAny<Guid?>(), It.IsAny<string?>()),
Times.Once);
}
@@ -283,7 +343,8 @@ public class ExternalSystemCachedCallEmissionTests
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>()))
// WasBuffered=false — the immediate HTTP attempt succeeded; S&F
// is bypassed entirely.
.ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false));
@@ -298,6 +359,7 @@ public class ExternalSystemCachedCallEmissionTests
var submit = forwarder.Telemetry[0];
Assert.Equal(AuditKind.CachedSubmit, submit.Audit.Kind);
Assert.Equal(AuditStatus.Submitted, submit.Audit.Status);
Assert.Equal(TestExecutionId, submit.Audit.ExecutionId);
Assert.Equal(trackedId, submit.Operational.TrackedOperationId);
Assert.Null(submit.Operational.TerminalAtUtc);
@@ -305,7 +367,10 @@ public class ExternalSystemCachedCallEmissionTests
Assert.Equal(AuditChannel.ApiOutbound, attempted.Audit.Channel);
Assert.Equal(AuditKind.ApiCallCached, attempted.Audit.Kind);
Assert.Equal(AuditStatus.Attempted, attempted.Audit.Status);
// Cached rows: CorrelationId = TrackedOperationId; ExecutionId is the
// per-execution id from the runtime context.
Assert.Equal(trackedId.Value, attempted.Audit.CorrelationId);
Assert.Equal(TestExecutionId, attempted.Audit.ExecutionId);
Assert.Equal("ERP.GetOrder", attempted.Audit.Target);
Assert.Equal(trackedId, attempted.Operational.TrackedOperationId);
Assert.Equal("Attempted", attempted.Operational.Status);
@@ -316,6 +381,7 @@ public class ExternalSystemCachedCallEmissionTests
Assert.Equal(AuditKind.CachedResolve, resolve.Audit.Kind);
Assert.Equal(AuditStatus.Delivered, resolve.Audit.Status);
Assert.Equal(trackedId.Value, resolve.Audit.CorrelationId);
Assert.Equal(TestExecutionId, resolve.Audit.ExecutionId);
Assert.Equal(trackedId, resolve.Operational.TrackedOperationId);
Assert.Equal("Delivered", resolve.Operational.Status);
// Terminal row carries TerminalAtUtc.
@@ -339,7 +405,8 @@ public class ExternalSystemCachedCallEmissionTests
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>()))
.ReturnsAsync(new ExternalCallResult(
false, null, "Permanent error: HTTP 422 bad payload", WasBuffered: false));
var forwarder = new CapturingForwarder();
@@ -381,7 +448,8 @@ public class ExternalSystemCachedCallEmissionTests
It.IsAny<IReadOnlyDictionary<string, object?>?>(),
InstanceName,
It.IsAny<CancellationToken>(),
It.IsAny<TrackedOperationId?>()))
It.IsAny<TrackedOperationId?>(),
It.IsAny<Guid?>(), It.IsAny<string?>()))
// 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));

View File

@@ -46,27 +46,27 @@ public class ExternalSystemCallAuditEmissionTests
private const string SourceScript = "ScriptActor:CheckPressure";
/// <summary>
/// Audit Log #23: a fixed execution-wide correlation id used by the
/// default <see cref="CreateHelper(IExternalSystemClient, IAuditWriter?)"/>
/// Audit Log #23: a fixed per-execution id used by the default
/// <see cref="CreateHelper(IExternalSystemClient, IAuditWriter?)"/>
/// overload so assertions can compare against a known value.
/// </summary>
private static readonly Guid TestCorrelationId = Guid.NewGuid();
private static readonly Guid TestExecutionId = Guid.NewGuid();
private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper(
IExternalSystemClient client,
IAuditWriter? auditWriter)
=> CreateHelper(client, auditWriter, TestCorrelationId);
=> CreateHelper(client, auditWriter, TestExecutionId);
private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper(
IExternalSystemClient client,
IAuditWriter? auditWriter,
Guid correlationId)
Guid executionId)
{
return new ScriptRuntimeContext.ExternalSystemHelper(
client,
InstanceName,
NullLogger.Instance,
correlationId,
executionId,
auditWriter,
SiteId,
SourceScript);
@@ -225,47 +225,54 @@ public class ExternalSystemCallAuditEmissionTests
Assert.Equal(SourceScript, evt.SourceScript);
// Outbound channel: Actor carries the calling script identity.
Assert.Equal(SourceScript, evt.Actor);
// Audit Log #23: the sync ApiCall row now carries the execution-wide
// correlation id the helper was constructed with.
Assert.Equal(TestCorrelationId, evt.CorrelationId);
// Audit Log #23: the sync ApiCall row carries the per-execution id the
// helper was constructed with in ExecutionId. CorrelationId is null —
// a sync one-shot call has no operation lifecycle.
Assert.Equal(TestExecutionId, evt.ExecutionId);
Assert.Null(evt.CorrelationId);
}
[Fact]
public async Task Call_SyncApiCall_StampsExecutionCorrelationId()
public async Task Call_SyncApiCall_StampsExecutionId_AndNullCorrelationId()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, "{}", null));
var writer = new CapturingAuditWriter();
var correlationId = Guid.NewGuid();
var executionId = Guid.NewGuid();
var helper = CreateHelper(client.Object, writer, correlationId);
var helper = CreateHelper(client.Object, writer, executionId);
await helper.Call("ERP", "GetOrder");
var evt = Assert.Single(writer.Events);
Assert.Equal(correlationId, evt.CorrelationId);
Assert.Equal(executionId, evt.ExecutionId);
// Sync one-shot call: CorrelationId is null (no operation lifecycle).
Assert.Null(evt.CorrelationId);
}
[Fact]
public async Task Call_TwoCallsOnSameHelper_ShareTheSameCorrelationId()
public async Task Call_TwoCallsOnSameHelper_ShareTheSameExecutionId()
{
var client = new Mock<IExternalSystemClient>();
client
.Setup(c => c.CallAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExternalCallResult(true, "{}", null));
var writer = new CapturingAuditWriter();
var correlationId = Guid.NewGuid();
var executionId = Guid.NewGuid();
var helper = CreateHelper(client.Object, writer, correlationId);
var helper = CreateHelper(client.Object, writer, executionId);
await helper.Call("ERP", "GetOrder");
await helper.Call("ERP", "GetCustomer");
Assert.Equal(2, writer.Events.Count);
// Both sync ApiCall rows from one execution carry the same id.
Assert.Equal(correlationId, writer.Events[0].CorrelationId);
Assert.Equal(correlationId, writer.Events[1].CorrelationId);
Assert.Equal(writer.Events[0].CorrelationId, writer.Events[1].CorrelationId);
// Both sync ApiCall rows from one execution carry the same ExecutionId.
Assert.Equal(executionId, writer.Events[0].ExecutionId);
Assert.Equal(executionId, writer.Events[1].ExecutionId);
Assert.Equal(writer.Events[0].ExecutionId, writer.Events[1].ExecutionId);
// Neither sync call carries a CorrelationId.
Assert.Null(writer.Events[0].CorrelationId);
Assert.Null(writer.Events[1].CorrelationId);
}
[Fact]

View File

@@ -60,7 +60,8 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable
private ScriptRuntimeContext.NotifyHelper CreateHelper(
IActorRef siteCommunicationActor,
string? sourceScript = null)
string? sourceScript = null,
Guid? executionId = null)
{
return new ScriptRuntimeContext.NotifyHelper(
_saf,
@@ -69,7 +70,8 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable
"Plant.Pump3",
sourceScript,
TimeSpan.FromSeconds(3),
NullLogger.Instance);
NullLogger.Instance,
executionId ?? Guid.NewGuid());
}
[Fact]
@@ -133,6 +135,27 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable
Assert.Equal("ScriptActor:MonitorSpeed", payload!.SourceScript);
}
[Fact]
public async Task Send_StampsExecutionId_OnTheNotificationSubmitPayload()
{
// Audit Log #23 (ExecutionId Task 5): Notify.Send must stamp the
// script run's ExecutionId onto the NotificationSubmit so it rides
// inside the serialized S&F payload to central, where the dispatcher
// echoes it onto the NotifyDeliver rows. This is the SAME id stamped
// onto the site-emitted NotifySend audit row.
var executionId = Guid.NewGuid();
var commProbe = CreateTestProbe();
var notify = CreateHelper(commProbe.Ref, executionId: executionId);
var notificationId = await notify.To("Operators").Send("Pump alarm", "Pump 3 tripped");
var buffered = await _saf.GetMessageByIdAsync(notificationId);
Assert.NotNull(buffered);
var payload = JsonSerializer.Deserialize<NotificationSubmit>(buffered!.PayloadJson);
Assert.NotNull(payload);
Assert.Equal(executionId, payload!.OriginExecutionId);
}
[Fact]
public async Task Send_WhenHelperHasNoSourceScript_LeavesSourceScriptNull()
{

View File

@@ -53,6 +53,12 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
private const string Subject = "Pump alarm";
private const string Body = "Pump 3 tripped";
/// <summary>
/// Audit Log #23: a fixed per-execution id so the NotifySend test can
/// assert <see cref="AuditEvent.ExecutionId"/> against a known value.
/// </summary>
private static readonly Guid TestExecutionId = Guid.NewGuid();
private readonly SqliteConnection _keepAlive;
private readonly StoreAndForwardStorage _storage;
private readonly StoreAndForwardService _saf;
@@ -102,6 +108,7 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
sourceScript,
TimeSpan.FromSeconds(3),
NullLogger.Instance,
TestExecutionId,
auditWriter);
}
@@ -214,12 +221,14 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable
// NotificationId is minted as Guid.NewGuid().ToString("N") — the 32-char
// hex form, which Guid.TryParse accepts. The audit row's CorrelationId
// must round-trip back to the same Guid value.
// must round-trip back to the same Guid value (the per-operation
// lifecycle id). ExecutionId carries the per-execution id instead.
Assert.True(Guid.TryParse(notificationId, out var expected),
$"NotificationId '{notificationId}' should be a parseable Guid");
var evt = writer.Events[0];
Assert.NotNull(evt.CorrelationId);
Assert.Equal(expected, evt.CorrelationId);
Assert.Equal(TestExecutionId, evt.ExecutionId);
}
[Fact]

View File

@@ -277,6 +277,86 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable
Assert.Equal(trackedId, _observer.Notifications[1].TrackedOperationId);
}
// ── Audit Log #23 (ExecutionId Task 4): ExecutionId / SourceScript ──
[Fact]
public async Task Attempt_CarriesExecutionIdAndSourceScript_FromBufferedMessage()
{
// A buffered cached call carries the originating script execution's
// ExecutionId + SourceScript. The retry sweep must surface both on the
// CachedCallAttemptContext handed to the observer so the audit bridge
// can stamp them on the retry-loop cached rows.
var executionId = Guid.NewGuid();
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => throw new HttpRequestException("HTTP 503"));
var trackedId = TrackedOperationId.New();
await _service.EnqueueAsync(
StoreAndForwardCategory.ExternalSystem,
"ERP",
"""{"payload":"x"}""",
originInstanceName: "Plant.Pump42",
maxRetries: 5,
retryInterval: TimeSpan.Zero,
attemptImmediateDelivery: false,
messageId: trackedId.ToString(),
executionId: executionId,
sourceScript: "Plant.Pump42/OnTick");
await _service.RetryPendingMessagesAsync();
var notification = Assert.Single(_observer.Notifications);
Assert.Equal(executionId, notification.ExecutionId);
Assert.Equal("Plant.Pump42/OnTick", notification.SourceScript);
}
[Fact]
public async Task Attempt_NullExecutionIdAndSourceScript_SurfaceAsNull()
{
// Back-compat: a row buffered without ExecutionId / SourceScript (legacy
// enqueue path) must surface them as null on the context, not throw.
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
_ => Task.FromResult(true));
var trackedId = await EnqueueBufferedAsync(
StoreAndForwardCategory.ExternalSystem, "ERP");
await _service.RetryPendingMessagesAsync();
var notification = Assert.Single(_observer.Notifications);
Assert.Null(notification.ExecutionId);
Assert.Null(notification.SourceScript);
}
[Fact]
public async Task TerminalResolve_CarriesExecutionIdAndSourceScript()
{
// The terminal Delivered notification must also carry the threaded
// provenance so the CachedResolve audit row is correlated.
var executionId = Guid.NewGuid();
_service.RegisterDeliveryHandler(StoreAndForwardCategory.CachedDbWrite,
_ => Task.FromResult(true));
var trackedId = TrackedOperationId.New();
await _service.EnqueueAsync(
StoreAndForwardCategory.CachedDbWrite,
"myDb",
"""{"payload":"x"}""",
originInstanceName: "Plant.Tank",
maxRetries: 3,
retryInterval: TimeSpan.Zero,
attemptImmediateDelivery: false,
messageId: trackedId.ToString(),
executionId: executionId,
sourceScript: "Plant.Tank/OnAlarm");
await _service.RetryPendingMessagesAsync();
var notification = Assert.Single(_observer.Notifications);
Assert.Equal(CachedCallAttemptOutcome.Delivered, notification.Outcome);
Assert.Equal(executionId, notification.ExecutionId);
Assert.Equal("Plant.Tank/OnAlarm", notification.SourceScript);
}
// ── Best-effort contract: observer throws must NOT corrupt retry bookkeeping ──
[Fact]

View File

@@ -26,7 +26,8 @@ public class NotificationForwarderTests : TestKit
private static StoreAndForwardMessage BufferedNotification(
string id = "msg-1", string listName = "Operators",
string subject = "Pump alarm", string message = "Pump 3 tripped",
string? originInstance = "Plant.Pump3", string? sourceScript = "alarmScript")
string? originInstance = "Plant.Pump3", string? sourceScript = "alarmScript",
Guid? originExecutionId = null)
{
var payload = JsonSerializer.Serialize(new NotificationSubmit(
NotificationId: id,
@@ -37,7 +38,8 @@ public class NotificationForwarderTests : TestKit
SourceSiteId: string.Empty,
SourceInstanceId: originInstance,
SourceScript: sourceScript,
SiteEnqueuedAt: DateTimeOffset.UtcNow));
SiteEnqueuedAt: DateTimeOffset.UtcNow,
OriginExecutionId: originExecutionId));
return new StoreAndForwardMessage
{
Id = id,
@@ -78,6 +80,33 @@ public class NotificationForwarderTests : TestKit
Assert.True(await deliverTask);
}
[Fact]
public async Task Deliver_PreservesOriginExecutionId_FromBufferedPayload()
{
// Audit Log #23: the buffered payload's OriginExecutionId is the per-run
// id stamped at Notify.Send time. The forwarder re-stamps only the four
// fields it authoritatively owns (NotificationId, ListName, SourceSiteId,
// SourceInstanceId) via the `with` expression — OriginExecutionId is
// preserved precisely BY being absent from that `with` block. This test
// pins that: if OriginExecutionId is ever added to the `with` expression
// (e.g. reset to null), the forwarded NotificationSubmit would lose the
// per-run id and central could not echo it onto NotifyDeliver rows.
var centralProbe = CreateTestProbe();
var forwarder = new NotificationForwarder(
centralProbe.Ref, "site-7", ForwardTimeout);
var executionId = Guid.NewGuid();
var msg = BufferedNotification(id: "msg-exec", originExecutionId: executionId);
var deliverTask = forwarder.DeliverAsync(msg);
var submit = centralProbe.ExpectMsg<NotificationSubmit>();
Assert.Equal(executionId, submit.OriginExecutionId);
centralProbe.Reply(new NotificationSubmitAck(submit.NotificationId, Accepted: true, Error: null));
Assert.True(await deliverTask);
}
[Fact]
public async Task Deliver_FallsBackToTarget_WhenPayloadListNameIsEmpty()
{

View File

@@ -293,6 +293,165 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
Assert.True(count >= 1);
}
// ── Audit Log #23 (ExecutionId Task 4): execution_id / source_script ──
[Fact]
public async Task EnqueueAsync_RoundTripsExecutionIdAndSourceScript()
{
// A cached call buffered on a transient failure carries the originating
// script execution's ExecutionId + SourceScript; both must survive a
// persist + read-back so the retry loop can stamp them on audit rows.
var executionId = Guid.NewGuid();
var message = CreateMessage("exec1", StoreAndForwardCategory.ExternalSystem);
message.ExecutionId = executionId;
message.SourceScript = "Plant.Pump42/OnTick";
await _storage.EnqueueAsync(message);
var retrieved = await _storage.GetMessageByIdAsync("exec1");
Assert.NotNull(retrieved);
Assert.Equal(executionId, retrieved!.ExecutionId);
Assert.Equal("Plant.Pump42/OnTick", retrieved.SourceScript);
}
[Fact]
public async Task EnqueueAsync_NullExecutionIdAndSourceScript_RoundTripAsNull()
{
// Non-cached-call enqueues (notifications) supply neither field — they
// must round-trip as null rather than throwing or coercing.
var message = CreateMessage("noexec1", StoreAndForwardCategory.Notification);
Assert.Null(message.ExecutionId);
Assert.Null(message.SourceScript);
await _storage.EnqueueAsync(message);
var retrieved = await _storage.GetMessageByIdAsync("noexec1");
Assert.NotNull(retrieved);
Assert.Null(retrieved!.ExecutionId);
Assert.Null(retrieved.SourceScript);
}
[Fact]
public async Task ExecutionIdAndSourceScript_SurviveRetrySweepRead()
{
// The retry sweep reads due rows via GetMessagesForRetryAsync; the new
// fields must be present on that read path too (it is the path that
// feeds the CachedCallAttemptContext).
var executionId = Guid.NewGuid();
var message = CreateMessage("sweep1", StoreAndForwardCategory.CachedDbWrite);
message.ExecutionId = executionId;
message.SourceScript = "Plant.Tank/OnAlarm";
message.LastAttemptAt = null; // due immediately
await _storage.EnqueueAsync(message);
var due = await _storage.GetMessagesForRetryAsync();
var row = Assert.Single(due, m => m.Id == "sweep1");
Assert.Equal(executionId, row.ExecutionId);
Assert.Equal("Plant.Tank/OnAlarm", row.SourceScript);
}
[Fact]
public async Task LegacyRowWithoutNewColumns_ReadsBackAsNull()
{
// Back-compat: a row persisted by a build that pre-dates the
// execution_id / source_script columns must still deserialize, with
// ExecutionId / SourceScript reading back as null. Simulate the legacy
// schema by dropping the table and recreating it without the columns,
// inserting directly, then running InitializeAsync (which ALTER-adds
// the columns) and reading the row back.
await using (var setup = new SqliteConnection($"Data Source={_dbName};Mode=Memory;Cache=Shared"))
{
await setup.OpenAsync();
await using var drop = setup.CreateCommand();
drop.CommandText = @"
DROP TABLE IF EXISTS sf_messages;
CREATE TABLE sf_messages (
id TEXT PRIMARY KEY,
category INTEGER NOT NULL,
target TEXT NOT NULL,
payload_json TEXT NOT NULL,
retry_count INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 50,
retry_interval_ms INTEGER NOT NULL DEFAULT 30000,
created_at TEXT NOT NULL,
last_attempt_at TEXT,
status INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
origin_instance TEXT
);
INSERT INTO sf_messages (id, category, target, payload_json, created_at, status)
VALUES ('legacy1', 0, 'ERP', '{}', '2026-01-01T00:00:00.0000000+00:00', 0);";
await drop.ExecuteNonQueryAsync();
}
// InitializeAsync must additively ALTER-in the new columns without
// disturbing the pre-existing legacy row.
await _storage.InitializeAsync();
var retrieved = await _storage.GetMessageByIdAsync("legacy1");
Assert.NotNull(retrieved);
Assert.Equal("legacy1", retrieved!.Id);
Assert.Null(retrieved.ExecutionId);
Assert.Null(retrieved.SourceScript);
}
[Fact]
public async Task MalformedExecutionId_ReadsBackAsNull_DoesNotAbortRetrySweep()
{
// Defensive read path: a corrupt (non-null, non-GUID) execution_id must
// be treated as "no execution id" rather than throwing FormatException
// — a single bad row must not abort the whole GetMessagesForRetryAsync
// sweep, which reads many rows. Persist two due rows, then corrupt the
// execution_id of one directly in the DB.
var goodId = Guid.NewGuid();
var good = CreateMessage("good1", StoreAndForwardCategory.ExternalSystem);
good.ExecutionId = goodId;
good.LastAttemptAt = null; // due immediately
await _storage.EnqueueAsync(good);
var bad = CreateMessage("bad1", StoreAndForwardCategory.ExternalSystem);
bad.ExecutionId = Guid.NewGuid();
bad.LastAttemptAt = null; // due immediately
await _storage.EnqueueAsync(bad);
await using (var conn = new SqliteConnection($"Data Source={_dbName};Mode=Memory;Cache=Shared"))
{
await conn.OpenAsync();
await using var corrupt = conn.CreateCommand();
corrupt.CommandText =
"UPDATE sf_messages SET execution_id = 'not-a-guid' WHERE id = 'bad1';";
await corrupt.ExecuteNonQueryAsync();
}
// The sweep must not throw; the corrupt row reads back with a null
// ExecutionId, the well-formed row keeps its value.
var due = await _storage.GetMessagesForRetryAsync();
Assert.Null(Assert.Single(due, m => m.Id == "bad1").ExecutionId);
Assert.Equal(goodId, Assert.Single(due, m => m.Id == "good1").ExecutionId);
// The single-row read path is equally defensive.
var retrieved = await _storage.GetMessageByIdAsync("bad1");
Assert.NotNull(retrieved);
Assert.Null(retrieved!.ExecutionId);
}
[Fact]
public async Task InitializeAsync_IsIdempotent_WhenColumnsAlreadyExist()
{
// The additive ALTER must not fail on a second InitializeAsync call
// (SQLite has no ADD COLUMN IF NOT EXISTS — the probe must skip it).
await _storage.InitializeAsync();
await _storage.InitializeAsync();
var message = CreateMessage("idem1", StoreAndForwardCategory.ExternalSystem);
message.ExecutionId = Guid.NewGuid();
await _storage.EnqueueAsync(message);
var retrieved = await _storage.GetMessageByIdAsync("idem1");
Assert.NotNull(retrieved);
Assert.Equal(message.ExecutionId, retrieved!.ExecutionId);
}
private static StoreAndForwardMessage CreateMessage(string id, StoreAndForwardCategory category)
{
return new StoreAndForwardMessage