From 6be26e281327707d88ec7f2e2d5061cb10117e0f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 16:53:25 -0400 Subject: [PATCH 01/19] docs(auditlog): ParentExecutionId cross-execution correlation design --- ...6-05-21-audit-parent-executionid-design.md | 222 ++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 docs/plans/2026-05-21-audit-parent-executionid-design.md diff --git a/docs/plans/2026-05-21-audit-parent-executionid-design.md b/docs/plans/2026-05-21-audit-parent-executionid-design.md new file mode 100644 index 0000000..5c29f19 --- /dev/null +++ b/docs/plans/2026-05-21-audit-parent-executionid-design.md @@ -0,0 +1,222 @@ +# Audit Log — Cross-Execution Correlation (`ParentExecutionId`) Design + +**Date:** 2026-05-21 +**Status:** Validated — ready for implementation planning. + +## Problem + +The Audit Log carries `ExecutionId` (`Guid?`) — a universal per-run correlation +value stamped on every audit row, identifying the originating script execution +or inbound API request. It is **per-execution and flat**: `WHERE ExecutionId = X` +returns everything *one* run did, but nothing links an execution to the +execution that *spawned* it. A call chain cannot be traced across the execution +boundary. + +Two cross-execution cases exist: + +1. **Inbound API request → routed site script.** An inbound HTTP request runs an + inbound method script (`InboundScriptExecutor`, central) which calls + `Route.Call(scriptName, params)`; that sends a `RouteToCallRequest` to a site + instance, which runs `scriptName` as a fresh site-side execution. The inbound + request and the routed site script get two unrelated `ExecutionId`s. +2. **Tag cascade.** Script A writes an attribute; the attribute change triggers + script B as a separate execution. A and B are unrelated. + +## Decision + +Add a dedicated, nullable **`ParentExecutionId`** (`Guid?`) column to the audit +row. Every execution still gets its own fresh `ExecutionId` (unchanged). An +execution *spawned by* another carries the spawner's `ExecutionId` in its +`ParentExecutionId`; a top-level (tag/timer/inbound/un-bridged) execution leaves +it null. Walking `ParentExecutionId → ExecutionId` recursively reconstructs the +chain as a tree. + +**First cut — in scope:** case 1 only, the **inbound → routed-site-script +bridge**. It is the most concrete case and the spawn point is an explicit, +threadable RPC (`RouteToCallRequest`). + +**Out of scope:** case 2 (tag cascade) — the trigger is data-driven and +decoupled; "which execution wrote the tag that triggered me" is not tracked +anywhere today. Deferred as a follow-up. The `ParentExecutionId` model +generalises to it with no schema change if that data is ever threaded. + +### Considered and rejected + +- **Reuse `ExecutionId`** — the routed script *adopts* the inbound request's + `ExecutionId` instead of generating its own. Cheaper (no new column) but + conflates two genuinely separate executions on two clusters, breaks the + invariant "one `ExecutionId` = one `ScriptRuntimeContext` run", and does not + generalise to tag cascade. +- **Point `ParentExecutionId` at the root** (flatten the chain to two levels) + instead of the immediate spawner — simpler queries but loses intermediate + hops, needs a separately threaded root id, and does not generalise. Rejected + in favour of the immediate-spawner tree. + +## Architecture & data flow + +The id propagated is the **inbound API request's `ExecutionId`**. The chain: + +1. **Mint the inbound request id once, early.** Today `AuditWriteMiddleware` + mints a `Guid.NewGuid()` late, only for the inbound row's `ExecutionId`. Move + the mint to the HTTP entry and stash it on `HttpContext.Items`, so both the + middleware (writes the `InboundRequest` row at request end) and + `InboundScriptExecutor` (needs it *before* the script runs) read the same id. +2. **Carry it on the routing RPC.** `RouteHelper.Call` builds a + `RouteToCallRequest`; an additive `ParentExecutionId` field is set from the + stashed inbound id. (`RouteHelper`'s own per-op GUID is a separate concern — + left alone.) +3. **Site side: thread it into the routed script's context.** The site handler + for `RouteToCallRequest` passes it to a new optional `parentExecutionId` ctor + param on `ScriptRuntimeContext` (sibling to the existing `executionId` + param). The routed script still generates its **own** fresh `ExecutionId`. +4. **Every emitter stamps `ParentExecutionId`** alongside `ExecutionId`. + +**Recursion (immediate-spawner tree).** A routed script that itself calls +`Route.Call` threads its own `ExecutionId` onward, so a grandchild's +`ParentExecutionId` points at its immediate spawner, not the root. Walk the tree +recursively to reconstruct any depth. + +**The inbound request's own row** (`InboundRequest` / `InboundAuthFailure`) is +top-level → `ParentExecutionId = NULL`. Only the routed site script and every +row it produces carry the pointer. + +## Schema changes (all additive, nullable — no backfill; pre-existing rows stay `NULL`) + +| Where | Change | +|---|---| +| `ScadaLink.Commons` | `AuditEvent.ParentExecutionId` (`Guid?`); `RouteToCallRequest.ParentExecutionId` (`Guid?`); `Notification.OriginParentExecutionId` (`Guid?`); `NotificationSubmit.OriginParentExecutionId` (`Guid?`). | +| Central MS SQL `AuditLog` | `ParentExecutionId uniqueidentifier NULL` column + partition-aligned index `IX_AuditLog_ParentExecution (ParentExecutionId)` (mirror `AddAuditLogExecutionId`). EF migration — additive nullable column is a metadata-only `ALTER`. | +| Central MS SQL `Notifications` | `OriginParentExecutionId uniqueidentifier NULL` column + EF migration (mirror `AddNotificationOriginExecutionId`). | +| Site SQLite `auditlog.db` `AuditLog` | `ParentExecutionId TEXT NULL` — added **via the idempotent `ALTER`-if-missing upgrade path** (per commit `5198b11`), never relying on `CREATE TABLE IF NOT EXISTS`. | +| gRPC `AuditEventDto` (`sitestream.proto`) | additive `parent_execution_id` field (next free number); `AuditEventDtoMapper` maps it both directions (Guid ↔ string; empty string ↔ null). | +| `ScriptRuntimeContext` | optional `parentExecutionId` ctor param + stored `_parentExecutionId` field. | + +`IX_AuditLog_ParentExecution` is load-bearing: the tree view's downward +recursive join seeks on it, and it backs the `parentExecutionId` filter. + +`SiteCalls` needs no new column — the cached telemetry packet carries the audit +half, which now has `ParentExecutionId` directly. + +## Emitter coverage — full (mirrors the `ExecutionId` rollout) + +Every audit row a routed-script run produces carries `ParentExecutionId`, so +`WHERE ParentExecutionId = X` returns the routed run's complete trust-boundary +footprint. + +| Emitter | `ParentExecutionId` source | +|---|---| +| Sync `ApiCall`, sync `DbWrite` | `ScriptRuntimeContext._parentExecutionId` (in scope) | +| Cached call script-side rows (`CachedSubmit`, immediate `Attempted`/`CachedResolve`) | `ScriptRuntimeContext._parentExecutionId` | +| Cached call **S&F retry-loop** rows (`CachedCallLifecycleBridge`) | threaded through the S&F buffered message → `CachedCallAttemptContext` → the bridge, as a sibling to the `ExecutionId` already threaded there | +| `NotifySend` (site, script-side) | `ScriptRuntimeContext._parentExecutionId` | +| `NotifyDeliver` (central dispatch) | `Notifications.OriginParentExecutionId` — rides on `NotificationSubmit`, persisted on the `Notifications` row, dispatcher stamps every `NotifyDeliver` row | +| Inbound `InboundRequest` / `InboundAuthFailure` | `NULL` — inbound is top-level | + +The threading reuses the carry points the `ExecutionId` rollout already opened +(S&F buffer, `NotificationSubmit` → `Notifications`); `ParentExecutionId` is a +sibling field at each, not a new boundary. + +## Recursive chain/tree view + +A new repository method `GetExecutionTreeAsync(Guid executionId)`: + +- **Walk up** to the root: iterative single-parent follow + (`SELECT TOP 1 ParentExecutionId WHERE ExecutionId = current AND + ParentExecutionId IS NOT NULL`) until null. Cheap — each execution has exactly + one parent. +- **Walk down** from the root: recursive CTE joining + `ParentExecutionId = ancestor.ExecutionId`, seeking on + `IX_AuditLog_ParentExecution`. `MAXRECURSION` capped (e.g. 32) — chains are + shallow; the cap guards against corrupt/pathological data. +- Returns a flat list of execution nodes: `ExecutionId`, `ParentExecutionId`, + row count, channels/statuses present, `SourceSiteId`/`SourceInstanceId`, + first/last `OccurredAtUtc`. The UI assembles the tree from the flat list. + +**UI.** New route `/audit/execution-tree?executionId=`, reached via a +"View execution chain" drill-in from any audit row and from the `ExecutionId` +column. Renders an expandable custom Blazor tree (no component frameworks); each +node shows the execution summary; clicking a node filters the Audit Log grid to +`?executionId=`. The tree is always rooted at the topmost ancestor, so the +reader sees the full chain regardless of which row they entered from. + +Plus the cheaper navigation affordances: `ParentExecutionId` grid column (short +form / monospace), a `ParentExecutionId` paste-filter, a `?parentExecutionId=` +query param, and a "View parent execution" drill-in (links +`?executionId=`). + +### Edge cases + +- **Parent with no rows of its own.** An execution that performed no + trust-boundary action emits no audit rows, yet a child still references it via + `ParentExecutionId`. The upward walk resolves the GUID but finds no rows for + that node → render it as a stub node ("execution with no audited actions"). +- **Purged parent.** A parent execution older than the 365-day central + retention has no rows → the upward walk stops there; the chain renders as far + as it resolves. +- **Cycle guard.** The `ParentExecutionId` graph is acyclic by construction + (each execution is minted fresh and its parent always pre-exists), but + `MAXRECURSION` bounds the downward CTE against corrupt data. + +## CLI / ManagementService + +- CLI: `scadalink audit query --parent-execution-id `; + `AuditLogQueryFilter` gains a `ParentExecutionId` single-value filter + dimension (mirror `ExecutionId`). +- ManagementService `/api/audit/query` + export endpoint and the CentralUI + export endpoints parse a `parentExecutionId` query param (lax-parse — + unparseable dropped). +- The tree view's data path: `GetExecutionTreeAsync` is exposed however the + existing Audit Log page sources its grid data — mirror that path; add a + ManagementService endpoint only if the page goes through it. +- **No CLI `audit tree` command in the first cut** — the tree is a UI forensic + affordance; the `--parent-execution-id` filter covers scripted use. Noted as a + possible follow-up. + +## Compatibility + +- Additive nullable columns; additive proto field; additive message-contract + fields — all version-compatible. No backfill; historical rows keep + `ParentExecutionId = NULL`. +- `ExecutionId` and `CorrelationId` semantics unchanged — every existing + drill-in keeps working. + +## Failure handling + +- Audit-write failure NEVER aborts the user-facing action — unchanged invariant; + `ParentExecutionId` is just another field on the row. +- Site `auditlog.db` schema change MUST use the idempotent `ALTER`-if-missing + path (commit `5198b11`); do not repeat the original `CREATE TABLE IF NOT + EXISTS` mistake. + +## Testing + +- Repository: query-by-`ParentExecutionId`; `GetExecutionTreeAsync` (multi-level + tree, stub-parent node, `MAXRECURSION` cap); migration smoke test. +- Emitter unit tests: each emitter stamps `ParentExecutionId`; the cached-call + lifecycle rows from one routed run share it; `NotifyDeliver` echoes + `Notifications.OriginParentExecutionId`. +- **Headline integration test:** an inbound API request that calls `Route.Call` + → the routed site script does a sync `ExternalSystem.Call`, a cached call, and + a `Notify.Send` → every resulting audit row (site + central) carries + `ParentExecutionId` = the inbound request's `ExecutionId`, while each has its + own distinct `ExecutionId`. +- Central UI: bUnit (column renders, filter maps, query param parsed, tree + assembled from the flat list) + Playwright (drill-in → tree → node click + filters the grid). + +## Out of scope / follow-ups + +- **Tag cascade (case 2)** — deferred. If the attribute-write path ever carries + the writing execution's id into the triggered script's `ScriptRuntimeContext`, + the same `ParentExecutionId` column and tree view cover it with no schema + change. +- CLI `audit tree` command — possible follow-up. +- Backfilling `ParentExecutionId` on historical audit rows — not done. + +## Constraints + +- Additive everywhere — nullable columns, additive proto/message fields, no + backfill. +- Never touch `infra/*`; `alog.md` is the locked v1 spec — do not modify it. +- Site `auditlog.db` schema change MUST use the idempotent `ALTER`-if-missing + path (commit `5198b11`). From e4b37e279830564bff9c68f5f9a85cb130c95c2f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 16:58:07 -0400 Subject: [PATCH 02/19] docs(auditlog): ParentExecutionId implementation plan + task tracking --- .../2026-05-21-audit-parent-executionid.md | 220 ++++++++++++++++++ ...-21-audit-parent-executionid.md.tasks.json | 19 ++ 2 files changed, 239 insertions(+) create mode 100644 docs/plans/2026-05-21-audit-parent-executionid.md create mode 100644 docs/plans/2026-05-21-audit-parent-executionid.md.tasks.json diff --git a/docs/plans/2026-05-21-audit-parent-executionid.md b/docs/plans/2026-05-21-audit-parent-executionid.md new file mode 100644 index 0000000..5bd672b --- /dev/null +++ b/docs/plans/2026-05-21-audit-parent-executionid.md @@ -0,0 +1,220 @@ +# Audit Log ParentExecutionId — 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 `ParentExecutionId` column to the Audit Log so an execution spawned by another (first cut: an inbound API request that routes to a site script) records a pointer back to its spawner, making audit call chains traceable across the execution boundary. + +**Architecture:** Additive nullable `ParentExecutionId` (`Guid`) on the audit row (Commons `AuditEvent`, central MS SQL `AuditLog`, site SQLite `auditlog.db`, gRPC `AuditEventDto`). The inbound API request's `ExecutionId` is minted once at the HTTP entry, threaded onto `RouteToCallRequest` → `ScriptCallRequest` → the routed script's `ScriptRuntimeContext` as a new `parentExecutionId`; the routed script still mints its own fresh `ExecutionId`. Every emitter stamps `ParentExecutionId` as a sibling to `ExecutionId` — through the S&F buffer for retry-loop cached rows and through `NotificationSubmit` → `Notifications.OriginParentExecutionId` for central `NotifyDeliver` rows. A recursive repository query plus a Central UI tree view reconstruct the chain. Validated design: `docs/plans/2026-05-21-audit-parent-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-parent-executionid` (already created) — never commit to `main`. TDD — write the failing test first, then the minimal implementation. Edit in place; never touch `infra/*` or `alog.md`; `docker/*` only if a task says so (none do). Stage with explicit `git add ` — never `git add .` / `commit -am`. Full solution stays green (`dotnet build ScadaLink.slnx` 0 warnings — `TreatWarningsAsErrors` is on; `dotnet test ScadaLink.slnx` for touched suites). Additive contract evolution only. Do not push. + +--- + +## Task 0: Prep — verify branch + baseline + +**Files:** none. + +**Steps:** confirm `git branch --show-current` is `feature/audit-parent-executionid`; run `dotnet build ScadaLink.slnx` and confirm it succeeds with 0 warnings. + +**Acceptance:** on the branch, solution builds clean. + +--- + +## Task 1: Foundation — `AuditEvent.ParentExecutionId`, central `AuditLog` column, repository query + +**Files:** +- Modify: `src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs` — add `Guid? ParentExecutionId` (sibling to `ExecutionId`, same XML-doc style). +- Modify: `src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs` — add `Guid? ParentExecutionId` single-value filter dimension (mirror `ExecutionId`). +- Modify: `src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs` — map the column; add index `IX_AuditLog_ParentExecution (ParentExecutionId)`. +- Create: EF migration under `src/ScadaLink.ConfigurationDatabase/Migrations/` — `AddAuditLogParentExecutionId` — `ParentExecutionId uniqueidentifier NULL` + the index. Mirror `20260521184044_AddAuditLogExecutionId` exactly (partition-aligned index, metadata-only `ALTER`). +- Modify: `src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs` — `QueryAsync` translates `filter.ParentExecutionId` to `e.ParentExecutionId == value` (mirror the `ExecutionId` clause). Keyset paging untouched. +- Test: `tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs` — `QueryAsync_FilterByParentExecutionId`; migration smoke if the suite has that pattern. + +**Approach:** purely additive; `ParentExecutionId` is `Guid?` everywhere. Generate the migration the same way `AddAuditLogExecutionId` was produced (match the repo's migration workflow). + +**Commit:** `feat(auditlog): ParentExecutionId column on AuditEvent + central AuditLog` + +--- + +## Task 2: Foundation — site SQLite + gRPC DTO + +**Files:** +- Modify: `src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs` — add `ParentExecutionId TEXT NULL` to the `auditlog.db` `AuditLog` table; the insert command binds it; `MapRow` reads it back. **Add the column via the idempotent `ALTER TABLE ... ADD COLUMN`-if-missing upgrade path** (the same path commit `5198b11` introduced for `ExecutionId` — locate it and extend it; do NOT rely on `CREATE TABLE IF NOT EXISTS` for the new column on an existing site DB). +- Modify: `src/ScadaLink.Communication/Protos/sitestream.proto` — add `string parent_execution_id` to `AuditEventDto` (next free field number; additive). Rebuild regenerates the C# stubs. +- Modify: `src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs` — `ToDto`/`FromDto` map `ParentExecutionId` ↔ `parent_execution_id` (Guid ↔ string; empty string ↔ null, mirroring the existing `ExecutionId` handling). +- Test: `tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs` — column present, round-trips, and the `ALTER`-if-missing path adds it to a pre-existing DB lacking the column; `tests/ScadaLink.Communication.Tests/AuditEventDtoMapperTests.cs` — `ParentExecutionId` round-trip incl. null. + +**Commit:** `feat(auditlog): ParentExecutionId on site SQLite schema + gRPC AuditEventDto` + +--- + +## Task 3: Inbound request id minting + `RouteToCallRequest.ParentExecutionId` + +**What:** The id propagated as `ParentExecutionId` is the inbound API request's `ExecutionId`. Today `AuditWriteMiddleware` mints it late, only for the inbound audit row. Mint it once early and stash it so `InboundScriptExecutor` can carry it onto the routing RPC. + +**Files:** +- Modify: `src/ScadaLink.Commons/Messages/InboundApi/RouteToInstanceRequest.cs` — add `Guid? ParentExecutionId` to the `RouteToCallRequest` record (additive — append as the last positional param with a default, or make it a settable init property; match how the codebase evolves records). +- Modify: `src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs` (+ `AuditWriteMiddlewareExtensions.cs` if the pipeline order needs it) — mint the request `ExecutionId` (`Guid.NewGuid()`) at the start of the request, stash it on `HttpContext.Items` under a well-known key (add a small constant, e.g. `InboundExecutionContext.HttpItemKey`); `EmitInboundAudit` reads that same id for the inbound row's `ExecutionId` instead of minting its own. +- Modify: `src/ScadaLink.InboundAPI/InboundScriptExecutor.cs` — read the stashed inbound `ExecutionId` from `HttpContext.Items` (or accept it as a parameter from the endpoint that has the `HttpContext`). +- Modify: `src/ScadaLink.InboundAPI/RouteHelper.cs` (~line where `RouteToCallRequest` is built) — set `ParentExecutionId` on the `RouteToCallRequest` from the inbound `ExecutionId`. Leave `RouteHelper`'s own per-op `CorrelationId` GUID alone — separate concern. +- Modify: `src/ScadaLink.InboundAPI/EndpointExtensions.cs` if the inbound `ExecutionId` must be plumbed from the endpoint into `InboundScriptExecutor`. +- Test: `tests/ScadaLink.InboundAPI.Tests/` — `AuditWriteMiddlewareTests` (inbound row uses the early-minted id; distinct per request); a `RouteHelper`/`InboundScriptExecutor` test that a routed `RouteToCallRequest` carries `ParentExecutionId` = the inbound request's `ExecutionId`. + +**Approach:** the inbound request's own audit row stays top-level — `ParentExecutionId` is NOT set on it (it remains `NULL`). Only the spawn id flows outward on `RouteToCallRequest`. If the early mint cannot cleanly be shared between middleware and executor, STOP and report before guessing the pipeline shape. + +**Commit:** `feat(inboundapi): mint inbound ExecutionId early, carry it as RouteToCallRequest.ParentExecutionId` + +--- + +## Task 4: Thread `ParentExecutionId` into the routed script's `ScriptRuntimeContext` + +**What:** Carry the `RouteToCallRequest.ParentExecutionId` site-side down to the routed script's `ScriptRuntimeContext`. The routed script still generates its own fresh `ExecutionId`. + +**Files:** +- Modify: `src/ScadaLink.Commons/Messages/ScriptExecution/ScriptCallRequest.cs` — add `Guid? ParentExecutionId` (additive). This is the message `RouteInboundApiCall` builds. +- Modify: `src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs` `RouteInboundApiCall` (~line 734) — set `ParentExecutionId = request.ParentExecutionId` on the `ScriptCallRequest` it builds from the `RouteToCallRequest`. +- Modify: `src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs` `HandleScriptCallRequest` (~line 319) — forward `request.ParentExecutionId` onward. +- Modify: `src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs` `HandleScriptCallRequest` (~line 175) — pass `ParentExecutionId` into the `ScriptExecutionActor` it spawns. +- Modify: `src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs` — add an optional `Guid? parentExecutionId = null` ctor param; thread it through `ExecuteScript` into `new ScriptRuntimeContext(...)`. +- Modify: `src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs` — add an optional `Guid? parentExecutionId = null` ctor param (sibling to the existing `executionId` param ~line 144); store `_parentExecutionId`; XML-doc it. Thread it to the helper sub-context types alongside `_executionId` (the inner `ExternalSystem`/`Database`/`Notify` helper structs at ~lines 386, 406, 1003 carry `_executionId` — give them `_parentExecutionId` too). +- Test: `tests/ScadaLink.SiteRuntime.Tests/` — a test that a `ScriptCallRequest` carrying `ParentExecutionId` produces a `ScriptRuntimeContext` whose `_parentExecutionId` is that value AND whose `ExecutionId` is freshly generated (distinct); a `RouteToCallRequest` → `ScriptCallRequest` mapping test on `DeploymentManagerActor`. + +**Note for implementer:** this task only threads the value — no emitter stamps it yet (Task 5). A normal (tag/timer) script run passes no `ParentExecutionId`, so `_parentExecutionId` stays `null`. Verify the helper sub-context plumbing matches exactly how `_executionId` is already threaded; if the ctor param ordering is awkward, mirror the `executionId` decision documented at `ScriptRuntimeContext.cs:396`. + +**Commit:** `feat(siteruntime): thread ParentExecutionId into the routed script's ScriptRuntimeContext` + +--- + +## Task 5: Site script-side emitters stamp `ParentExecutionId` + +**What:** Every audit row a `ScriptRuntimeContext` emits gets `ParentExecutionId = _parentExecutionId` alongside `ExecutionId = _executionId`. + +**Files:** +- Modify: `src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs`: + - Sync `ApiCall` (`BuildCallAuditEvent` / the sync emission ~line 932): set `ParentExecutionId = _parentExecutionId`. + - Cached script-side rows (`CachedSubmit`, immediate `ApiCallCached`/`CachedResolve` ~lines 582, 693, 759): set `ParentExecutionId = _parentExecutionId`. + - `NotifySend` emission: set `ParentExecutionId = _parentExecutionId`. +- Modify: `src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs` + `AuditingDbCommand.cs` — thread `_parentExecutionId` (sibling to the audit `_executionId` already threaded); sync `DbWrite` and cached DB-write rows set `ParentExecutionId = _parentExecutionId`. +- Test: extend `tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs`, `DatabaseSyncEmissionTests.cs`, `ExternalSystemCachedCallEmissionTests.cs`, `DatabaseCachedWriteEmissionTests.cs`, `NotifySendAuditEmissionTests.cs`, `ExecutionCorrelationContextTests.cs` — assert `ParentExecutionId` is the context's `_parentExecutionId` on every emitted row; assert it is `null` when the context was constructed without one. + +**Commit:** `feat(auditlog): site script-side emitters stamp ParentExecutionId` + +--- + +## Task 6: Cached S&F retry-loop rows carry `ParentExecutionId` + +**What:** Thread `ParentExecutionId` through the store-and-forward buffer so the retry-loop cached audit rows (`CachedCallLifecycleBridge`) carry it — a sibling to the `ExecutionId` the `ExecutionId` rollout already threaded through this exact path. + +**Files:** +- Modify: the S&F buffered cached-call message / payload in `src/ScadaLink.StoreAndForward/` (`StoreAndForwardService.cs` and the buffered message type — find where `ExecutionId` was added in the `ExecutionId` rollout's Task 4) — carry `ParentExecutionId` alongside. +- Modify: `CachedCallAttemptContext` (in `src/ScadaLink.StoreAndForward/` / referenced by `src/ScadaLink.Commons/Interfaces/Services/ICachedCallLifecycleObserver.cs`) — add a `ParentExecutionId` field beside `ExecutionId`. +- Modify: `src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs` `BuildPacket` — set `ParentExecutionId` from the context, beside the existing `ExecutionId`. +- Modify: the enqueue path (`ExternalSystem.CachedCall` / `Database.CachedWrite` in `ScriptRuntimeContext.cs` ~line 520, where `executionId: _executionId` is already passed into the buffered message) — also write `_parentExecutionId` into the buffered message. +- Test: `tests/ScadaLink.AuditLog.Tests/` cached-telemetry tests + `tests/ScadaLink.StoreAndForward.Tests/` — retry-loop rows carry the originating `ParentExecutionId` (incl. `null` for a non-routed run). + +**Note for implementer:** the threading boundary is already open from the `ExecutionId` rollout — this is one more field at each existing carry point, not a new boundary. If the buffered message cannot cleanly carry it, STOP and report. + +**Commit:** `feat(auditlog): thread ParentExecutionId through S&F for retry-loop cached rows` + +--- + +## Task 7: Central `NotifyDeliver` rows carry `ParentExecutionId` + +**Files:** +- Modify: `src/ScadaLink.Commons/Entities/Notifications/Notification.cs` — add `Guid? OriginParentExecutionId` (sibling to `OriginExecutionId`). +- Modify: `src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs` — `NotificationSubmit` carries `Guid? OriginParentExecutionId` (additive). +- Modify: `src/ScadaLink.ConfigurationDatabase/` — EF config for `Notifications` + a new migration `AddNotificationOriginParentExecutionId` (`Notifications.OriginParentExecutionId uniqueidentifier NULL`). Mirror `20260521193048_AddNotificationOriginExecutionId`. +- Modify: the site `NotifySend` forward path — the routed run's `_parentExecutionId` (on the `NotifySend` audit row from Task 5) also rides on the `NotificationSubmit` (set it where the submit is built — `ScriptRuntimeContext` `Notify.Send` / the S&F notification forwarder, beside `OriginExecutionId`). +- Modify: `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs` — persist `OriginParentExecutionId` on insert; `BuildNotifyDeliverEvent` sets `ParentExecutionId = notification.OriginParentExecutionId`. +- Test: `tests/ScadaLink.NotificationOutbox.Tests/` — `NotifyDeliver` rows echo `OriginParentExecutionId`; `tests/ScadaLink.Commons.Tests/` contract shape. + +**Commit:** `feat(auditlog): NotifyDeliver rows carry the originating ParentExecutionId` + +--- + +## Task 8: Repository — `GetExecutionTreeAsync` + +**What:** A repository method that, given any `ExecutionId`, returns the whole execution chain rooted at the topmost ancestor — for the Central UI tree view. + +**Files:** +- Create: `src/ScadaLink.Commons/Types/Audit/ExecutionTreeNode.cs` — a record: `ExecutionId`, `ParentExecutionId`, `RowCount`, channels present, statuses present, `SourceSiteId`, `SourceInstanceId`, `FirstOccurredAtUtc`, `LastOccurredAtUtc`. +- Modify: `src/ScadaLink.Commons/Interfaces/` — the Audit Log repository interface gains `Task> GetExecutionTreeAsync(Guid executionId, CancellationToken ct)`. +- Modify: `src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs` — implement it: + 1. **Walk up** to the root — iterative `SELECT TOP 1 ParentExecutionId FROM AuditLog WHERE ExecutionId = @cur AND ParentExecutionId IS NOT NULL` until none; the last `ExecutionId` with no parent is the root. Cap the loop (e.g. 32) against corrupt data. + 2. **Walk down** — a recursive CTE seeded at the root, joining `child.ParentExecutionId = parent.ExecutionId`; `OPTION (MAXRECURSION 32)`. Project each distinct `ExecutionId` with the summary aggregates (`GROUP BY`). + Use `FromSqlInterpolated`/raw SQL for the recursive CTE (EF Core cannot express it in LINQ); keep the SQL append-only-safe (SELECT only). +- Test: `tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs` — `GetExecutionTree_MultiLevelChain` (3-level chain returns all nodes rooted at the ancestor regardless of the entry node); `GetExecutionTree_StubParentNode` (a `ParentExecutionId` referencing an execution with no rows of its own yields a node with `RowCount = 0` / is surfaced as referenced); `GetExecutionTree_RespectsMaxRecursion`. + +**Note for implementer:** chains are shallow (1–2 levels typical). The `ParentExecutionId` graph is acyclic by construction; `MAXRECURSION` is a guard, not a routine limit. A purged parent simply ends the upward walk. + +**Commit:** `feat(auditlog): GetExecutionTreeAsync recursive execution-chain query` + +--- + +## Task 9: Central UI — `ParentExecutionId` column, filter, parent drill-in + +**Files:** +- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor` (+ `.razor.cs`) — add `ParentExecutionId` to the column set (short form / monospace, like `ExecutionId`); it participates in the existing resize/reorder + `ColumnOrder`. +- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor` (+ `.razor.cs`) + `AuditQueryModel.cs` — a `ParentExecutionId` paste text-filter; `ToFilter` maps it to `AuditLogQueryFilter.ParentExecutionId`. +- Modify: `src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs` — `ApplyQueryStringFilters` accepts `?parentExecutionId=`; `BuildExportUrl` emits it. +- Add a "View parent execution" row/drilldown action (in `AuditDrilldownDrawer` and/or a grid row action) linking `/audit/log?executionId=`, shown only when the row has a non-null `ParentExecutionId`. Mirror the existing `?executionId=` drill-in. +- Test: `tests/ScadaLink.CentralUI.Tests/` bUnit (column renders, filter maps, query-param parsed, drill-in hidden when `ParentExecutionId` null); `tests/ScadaLink.CentralUI.PlaywrightTests/Audit/` (parent drill-in filters the grid). + +Use the `frontend-design` skill for the column/filter/drill-in styling. Custom Blazor + Bootstrap only — no component frameworks. + +**Commit:** `feat(centralui): ParentExecutionId column, filter and parent drill-in on the Audit Log page` + +--- + +## Task 10: Central UI — execution-chain tree view + +**What:** A page that renders the full execution chain (rooted at the topmost ancestor) as an expandable tree, reached via a "View execution chain" drill-in. + +**Files:** +- Create: `src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor` (+ `.razor.cs`) — route `/audit/execution-tree`, accepts `?executionId=`; calls `GetExecutionTreeAsync` via the same data path the Audit Log page uses for its grid (mirror that — repository service in-process, or a ManagementService endpoint if the grid goes through one; if the latter, add the endpoint in Task 11). +- Create: `src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor` (+ `.razor.cs` + `.razor.css`) — a custom recursive Blazor tree component: assembles the tree from the flat `ExecutionTreeNode` list, renders expandable nodes each showing the execution summary (id short form, row count, channels/statuses, site/instance, time span); a node referenced as a parent but with `RowCount = 0` renders as a stub ("execution with no audited actions"); clicking a node navigates to `/audit/log?executionId=`. +- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor` (+ `.razor.cs`) and/or `AuditResultsGrid` — add a "View execution chain" action linking `/audit/execution-tree?executionId=`. +- Modify: the Central UI `Audit` nav group if the page should be linkable (decide — it is primarily a drill-in target; a nav entry is optional). +- Test: `tests/ScadaLink.CentralUI.Tests/` bUnit (tree assembled correctly from a flat list incl. multi-level + stub node; node click navigates); `tests/ScadaLink.CentralUI.PlaywrightTests/Audit/` (drill-in → tree renders → node click filters the Audit Log grid). + +Use the `frontend-design` skill for the tree component. Clean, corporate, internal-use aesthetic; custom component, no frameworks. + +**Commit:** `feat(centralui): execution-chain tree view on the Audit Log page` + +--- + +## Task 11: CLI + ManagementService — `ParentExecutionId` filter + +**Files:** +- Modify: `src/ScadaLink.CLI/Commands/AuditCommands.cs` + `AuditQueryHelpers.cs` — `audit query --parent-execution-id `; `AuditQueryArgs` + `BuildQueryString` emit `parentExecutionId`. +- Modify: `src/ScadaLink.ManagementService/AuditEndpoints.cs` `ParseFilter` — parse `parentExecutionId` query param into `AuditLogQueryFilter.ParentExecutionId` (lax-parse — unparseable dropped). +- Modify: `src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs` `ParseFilter` — same. +- If Task 10's tree page goes through ManagementService rather than the repository in-process: add `GET /api/audit/execution-tree?executionId=` to `AuditEndpoints.cs` returning the `ExecutionTreeNode` list. Otherwise skip this bullet. No CLI `audit tree` command in the first cut. +- Test: `tests/ScadaLink.CLI.Tests/`, `tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs`. + +**Commit:** `feat(audit): ParentExecutionId filter in the CLI and ManagementService` + +--- + +## Task 12: End-to-end integration test + docs + +**Files:** +- Create: `tests/ScadaLink.IntegrationTests/AuditLog/ParentExecutionIdCorrelationTests.cs` — boot a site+central pair; issue an inbound API request whose method script calls `Route.Call` into a site instance; the routed site script does a sync `ExternalSystem.Call`, a cached call, and a `Notify.Send`. Assert: every audit row the routed run produced (site + central, sync + cached lifecycle + `NotifySend`/`NotifyDeliver`) carries `ParentExecutionId` = the inbound request's `ExecutionId`; each routed-run row has its own distinct `ExecutionId`; the inbound `InboundRequest` row has `ParentExecutionId = NULL`. Assert `GetExecutionTreeAsync` returns both executions in one chain. +- Modify: `docs/requirements/Component-AuditLog.md` — add `ParentExecutionId` to the `AuditLog` schema table and the index list (`IX_AuditLog_ParentExecution`); extend the `ExecutionId vs CorrelationId` section with a paragraph on `ParentExecutionId` (cross-execution correlation; inbound→routed bridge; immediate-spawner tree; tag cascade deferred). (Do NOT modify `alog.md`.) +- Modify: `CLAUDE.md` — under the Centralized Audit Log decisions, one line noting `ParentExecutionId` as the cross-execution spawn pointer (inbound→routed-site-script bridge; tag cascade deferred). +- Modify: component #23 summary in `CLAUDE.md`'s Current Component List if it enumerates correlation columns (keep it in sync). + +**Commit:** `test(auditlog): end-to-end ParentExecutionId correlation + docs` + +--- + +## Final review + +Dispatch a final cross-cutting review of the whole branch; full `dotnet build ScadaLink.slnx` (0 warnings) + `dotnet test ScadaLink.slnx`; hand back to the user for the push/merge/redeploy decision (do not push). + +## Dependency summary + +0 blocks all. 1 ← 0. 2 ← 1. 3 ← 0. 4 ← 3. 5 ← 4, 2. 6 ← 5. 7 ← 5, 1. 8 ← 1. 9 ← 1. 10 ← 8, 9. 11 ← 1. 12 ← 5, 6, 7, 10, 11. +Execution order: 0 → 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12 → final review. diff --git a/docs/plans/2026-05-21-audit-parent-executionid.md.tasks.json b/docs/plans/2026-05-21-audit-parent-executionid.md.tasks.json new file mode 100644 index 0000000..7f7b49b --- /dev/null +++ b/docs/plans/2026-05-21-audit-parent-executionid.md.tasks.json @@ -0,0 +1,19 @@ +{ + "planPath": "docs/plans/2026-05-21-audit-parent-executionid.md", + "tasks": [ + {"id": 0, "subject": "Task 0: Prep — verify branch + baseline", "status": "pending"}, + {"id": 1, "subject": "Task 1: Foundation — AuditEvent.ParentExecutionId + central AuditLog column", "status": "pending", "blockedBy": [0]}, + {"id": 2, "subject": "Task 2: Foundation — site SQLite + gRPC DTO", "status": "pending", "blockedBy": [1]}, + {"id": 3, "subject": "Task 3: Inbound request id minting + RouteToCallRequest.ParentExecutionId", "status": "pending", "blockedBy": [0]}, + {"id": 4, "subject": "Task 4: Thread ParentExecutionId into routed script ScriptRuntimeContext", "status": "pending", "blockedBy": [3]}, + {"id": 5, "subject": "Task 5: Site script-side emitters stamp ParentExecutionId", "status": "pending", "blockedBy": [4, 2]}, + {"id": 6, "subject": "Task 6: Cached S&F retry-loop rows carry ParentExecutionId", "status": "pending", "blockedBy": [5]}, + {"id": 7, "subject": "Task 7: Central NotifyDeliver rows carry ParentExecutionId", "status": "pending", "blockedBy": [5, 1]}, + {"id": 8, "subject": "Task 8: Repository — GetExecutionTreeAsync", "status": "pending", "blockedBy": [1]}, + {"id": 9, "subject": "Task 9: Central UI — ParentExecutionId column, filter, parent drill-in", "status": "pending", "blockedBy": [1]}, + {"id": 10, "subject": "Task 10: Central UI — execution-chain tree view", "status": "pending", "blockedBy": [8, 9]}, + {"id": 11, "subject": "Task 11: CLI + ManagementService — ParentExecutionId filter", "status": "pending", "blockedBy": [1]}, + {"id": 12, "subject": "Task 12: End-to-end integration test + docs", "status": "pending", "blockedBy": [5, 6, 7, 10, 11]} + ], + "lastUpdated": "2026-05-21" +} From 0a8709e5c53ebc7e1ccf2a8f653816c0b3b2247e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 17:04:39 -0400 Subject: [PATCH 03/19] feat(auditlog): ParentExecutionId column on AuditEvent + central AuditLog --- .../Entities/Audit/AuditEvent.cs | 7 + .../Types/Audit/AuditLogQueryFilter.cs | 5 +- .../AuditLogEntityTypeConfiguration.cs | 4 + ...4_AddAuditLogParentExecutionId.Designer.cs | 1636 +++++++++++++++++ ...0521210254_AddAuditLogParentExecutionId.cs | 59 + .../ScadaLinkDbContextModelSnapshot.cs | 7 + .../Repositories/AuditLogRepository.cs | 18 +- .../AuditLogEntityTypeConfigurationTests.cs | 15 +- .../Repositories/AuditLogRepositoryTests.cs | 33 +- 9 files changed, 1772 insertions(+), 12 deletions(-) create mode 100644 src/ScadaLink.ConfigurationDatabase/Migrations/20260521210254_AddAuditLogParentExecutionId.Designer.cs create mode 100644 src/ScadaLink.ConfigurationDatabase/Migrations/20260521210254_AddAuditLogParentExecutionId.cs diff --git a/src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs b/src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs index 7bc98af..dcb656f 100644 --- a/src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs +++ b/src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs @@ -33,6 +33,13 @@ public sealed record AuditEvent /// public Guid? ExecutionId { get; init; } + /// + /// of the execution that spawned this run, when this + /// run was spawned by another; null for top-level runs. Lets a spawned + /// execution point back at its spawner for cross-run correlation. + /// + public Guid? ParentExecutionId { get; init; } + /// Site id where the action originated; null for central-direct events. public string? SourceSiteId { get; init; } diff --git a/src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs b/src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs index dd902a0..b042cc2 100644 --- a/src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs +++ b/src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs @@ -12,8 +12,8 @@ namespace ScadaLink.Commons.Types.Audit; /// the spec sense — is inclusive and is /// inclusive of the upper bound; the repository SQL uses >= / <= /// respectively. All filter dimensions are AND-combined with one another. The -/// single-value and -/// dimensions constrain on equality when set. +/// single-value , and +/// dimensions constrain on equality when set. /// public sealed record AuditLogQueryFilter( IReadOnlyList? Channels = null, @@ -24,5 +24,6 @@ public sealed record AuditLogQueryFilter( string? Actor = null, Guid? CorrelationId = null, Guid? ExecutionId = null, + Guid? ParentExecutionId = null, DateTime? FromUtc = null, DateTime? ToUtc = null); diff --git a/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs b/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs index 561ec11..fbd3f1d 100644 --- a/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs +++ b/src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs @@ -93,6 +93,10 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration e.ParentExecutionId) + .HasFilter("[ParentExecutionId] IS NOT NULL") + .HasDatabaseName("IX_AuditLog_ParentExecution"); + builder.HasIndex(e => new { e.Channel, e.Status, e.OccurredAtUtc }) .IsDescending(false, false, true) .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred"); diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260521210254_AddAuditLogParentExecutionId.Designer.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260521210254_AddAuditLogParentExecutionId.Designer.cs new file mode 100644 index 0000000..7535c1f --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260521210254_AddAuditLogParentExecutionId.Designer.cs @@ -0,0 +1,1636 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ScadaLink.ConfigurationDatabase; + +#nullable disable + +namespace ScadaLink.ConfigurationDatabase.Migrations +{ + [DbContext(typeof(ScadaLinkDbContext))] + [Migration("20260521210254_AddAuditLogParentExecutionId")] + partial class AddAuditLogParentExecutionId + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditEvent", b => + { + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetime2"); + + b.Property("Actor") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + + b.Property("DurationMs") + .HasColumnType("int"); + + b.Property("ErrorDetail") + .HasColumnType("nvarchar(max)"); + + b.Property("ErrorMessage") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("ExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("Extra") + .HasColumnType("nvarchar(max)"); + + b.Property("ForwardState") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("ParentExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("PayloadTruncated") + .HasColumnType("bit"); + + b.Property("RequestSummary") + .HasColumnType("nvarchar(max)"); + + b.Property("ResponseSummary") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceInstanceId") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("SourceScript") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("SourceSiteId") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Target") + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.HasKey("EventId", "OccurredAtUtc"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("IX_AuditLog_CorrelationId") + .HasFilter("[CorrelationId] IS NOT NULL"); + + b.HasIndex("EventId") + .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"); + + b.HasIndex("ParentExecutionId") + .HasDatabaseName("IX_AuditLog_ParentExecution") + .HasFilter("[ParentExecutionId] IS NOT NULL"); + + b.HasIndex("SourceSiteId", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Site_Occurred"); + + b.HasIndex("Target", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Target_Occurred") + .HasFilter("[Target] IS NOT NULL"); + + b.HasIndex("Channel", "Status", "OccurredAtUtc") + .IsDescending(false, false, true) + .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred"); + + b.ToTable("AuditLog", (string)null); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("AfterStateJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("User") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("EntityId"); + + b.HasIndex("EntityType"); + + b.HasIndex("Timestamp"); + + b.HasIndex("User"); + + b.ToTable("AuditLogEntries"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.SiteCall", b => + { + b.Property("TrackedOperationId") + .HasMaxLength(36) + .IsUnicode(false) + .HasColumnType("varchar(36)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SourceSite") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Target") + .IsRequired() + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.Property("TerminalAtUtc") + .HasColumnType("datetime2"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("TrackedOperationId"); + + b.HasIndex("SourceSite", "CreatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Source_Created"); + + b.HasIndex("Status", "UpdatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Status_Updated"); + + b.ToTable("SiteCalls", (string)null); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DeployedConfigSnapshots"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ErrorMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.HasIndex("DeploymentId") + .IsUnique(); + + b.HasIndex("InstanceId"); + + b.ToTable("DeploymentRecords"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.SystemArtifactDeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PerSiteStatus") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.ToTable("SystemArtifactDeploymentRecords"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.DatabaseConnectionDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("DatabaseConnectionDefinitions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthConfiguration") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("EndpointUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ExternalSystemDefinitions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalSystemDefinitionId") + .HasColumnType("int"); + + b.Property("HttpMethod") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalSystemDefinitionId", "Name") + .IsUnique(); + + b.ToTable("ExternalSystemMethods"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApprovedApiKeyIds") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Script") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TimeoutSeconds") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiMethods"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentAreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentAreaId"); + + b.HasIndex("SiteId", "ParentAreaId", "Name") + .IsUnique() + .HasFilter("[ParentAreaId] IS NOT NULL"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("UniqueName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("SiteId", "UniqueName") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlarmCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("PriorityLevelOverride") + .HasColumnType("int"); + + b.Property("TriggerConfigurationOverride") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AlarmCanonicalName") + .IsUnique(); + + b.ToTable("InstanceAlarmOverrides"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("OverrideValue") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceAttributeOverrides"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DataConnectionId") + .HasColumnType("int"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DataConnectionId"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceConnectionBindings"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.Notification", b => + { + b.Property("NotificationId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeliveredAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ListName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NextAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("OriginExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("ResolvedTargets") + .HasColumnType("nvarchar(max)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SiteEnqueuedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SourceInstanceId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceScript") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceSiteId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TypeData") + .HasColumnType("nvarchar(max)"); + + b.HasKey("NotificationId"); + + b.HasIndex("SourceSiteId", "CreatedAt"); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("NotificationLists"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotificationListId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NotificationListId"); + + b.ToTable("NotificationRecipients"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.SmtpConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ConnectionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("Credentials") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("FromAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MaxConcurrentConnections") + .HasColumnType("int"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.Property("TlsMode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.ToTable("SmtpConfigurations"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Scripts.SharedScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SharedScripts"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.LdapGroupMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("LdapGroupName") + .IsUnique(); + + b.ToTable("LdapGroupMappings"); + + b.HasData( + new + { + Id = 1, + LdapGroupName = "SCADA-Admins", + Role = "Admin" + }, + new + { + Id = 2, + LdapGroupName = "SCADA-Designers", + Role = "Design" + }, + new + { + Id = 3, + LdapGroupName = "SCADA-Deploy-All", + Role = "Deployment" + }, + new + { + Id = 4, + LdapGroupName = "SCADA-Deploy-SiteA", + Role = "Deployment" + }); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupMappingId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.HasIndex("LdapGroupMappingId", "SiteId") + .IsUnique(); + + b.ToTable("SiteScopeRules"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BackupConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("FailoverRetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(3); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PrimaryConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("DataConnections"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("GrpcNodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("GrpcNodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SiteIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SiteIdentifier") + .IsUnique(); + + b.ToTable("Sites"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FolderId") + .HasColumnType("int"); + + b.Property("IsDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OwnerCompositionId") + .HasColumnType("int"); + + b.Property("ParentTemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.HasIndex("Name") + .IsUnique() + .HasFilter("[IsDerived] = 0"); + + b.HasIndex("ParentTemplateId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OnTriggerScriptId") + .HasColumnType("int"); + + b.Property("PriorityLevel") + .HasColumnType("int"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAlarms"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DataSourceReference") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("Value") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAttributes"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateComposition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ComposedTemplateId") + .HasColumnType("int"); + + b.Property("InstanceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ComposedTemplateId"); + + b.HasIndex("TemplateId", "InstanceName") + .IsUnique(); + + b.ToTable("TemplateCompositions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentFolderId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentFolderId", "Name") + .IsUnique() + .HasFilter("[ParentFolderId] IS NOT NULL"); + + b.ToTable("TemplateFolders"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("MinTimeBetweenRuns") + .HasColumnType("time"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateScripts"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.HasOne("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", null) + .WithMany() + .HasForeignKey("ExternalSystemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Area", null) + .WithMany("Children") + .HasForeignKey("ParentAreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Area", null) + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("AlarmOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("AttributeOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.HasOne("ScadaLink.Commons.Entities.Sites.DataConnection", null) + .WithMany() + .HasForeignKey("DataConnectionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("ConnectionBindings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.HasOne("ScadaLink.Commons.Entities.Notifications.NotificationList", null) + .WithMany("Recipients") + .HasForeignKey("NotificationListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b => + { + b.HasOne("ScadaLink.Commons.Entities.Security.LdapGroupMapping", null) + .WithMany() + .HasForeignKey("LdapGroupMappingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b => + { + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ParentTemplateId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAlarm", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Alarms") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAttribute", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Attributes") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateComposition", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ComposedTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Compositions") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateFolder", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("ParentFolderId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateScript", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Scripts") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.Navigation("AlarmOverrides"); + + b.Navigation("AttributeOverrides"); + + b.Navigation("ConnectionBindings"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b => + { + b.Navigation("Recipients"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.Navigation("Alarms"); + + b.Navigation("Attributes"); + + b.Navigation("Compositions"); + + b.Navigation("Scripts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260521210254_AddAuditLogParentExecutionId.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260521210254_AddAuditLogParentExecutionId.cs new file mode 100644 index 0000000..b7d65cc --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260521210254_AddAuditLogParentExecutionId.cs @@ -0,0 +1,59 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ScadaLink.ConfigurationDatabase.Migrations +{ + /// + /// Adds the ParentExecutionId correlation column to the centralized + /// AuditLog table (#23). ParentExecutionId carries the + /// ExecutionId of the execution that spawned this run, letting a + /// spawned execution point back at its spawner — a sibling to the universal + /// per-run ExecutionId. + /// + /// The change is purely additive: + /// 1. ParentExecutionId uniqueidentifier NULL is added with no default, + /// so the operation is a metadata-only ALTER TABLE … ADD — it does + /// NOT rewrite the monthly-partitioned AuditLog table, and + /// historical rows stay NULL (no backfill). + /// 2. IX_AuditLog_ParentExecution is created via raw SQL so it lands on + /// the ps_AuditLog_Month(OccurredAtUtc) partition scheme, matching + /// every other IX_AuditLog_* index. Keeping it partition-aligned + /// preserves the partition-switch purge path (see + /// AuditLogRepository.SwitchOutPartitionAsync). + /// + public partial class AddAuditLogParentExecutionId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ParentExecutionId", + 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_Execution (filtered, aligned). + migrationBuilder.Sql(@" +CREATE NONCLUSTERED INDEX IX_AuditLog_ParentExecution +ON dbo.AuditLog (ParentExecutionId) +WHERE ParentExecutionId IS NOT NULL +ON ps_AuditLog_Month(OccurredAtUtc);"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(@" +IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_ParentExecution' AND object_id = OBJECT_ID('dbo.AuditLog')) + DROP INDEX IX_AuditLog_ParentExecution ON dbo.AuditLog;"); + + migrationBuilder.DropColumn( + name: "ParentExecutionId", + table: "AuditLog"); + } + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs index ba22cb1..47d71da 100644 --- a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs @@ -96,6 +96,9 @@ namespace ScadaLink.ConfigurationDatabase.Migrations .IsUnicode(false) .HasColumnType("varchar(32)"); + b.Property("ParentExecutionId") + .HasColumnType("uniqueidentifier"); + b.Property("PayloadTruncated") .HasColumnType("bit"); @@ -149,6 +152,10 @@ namespace ScadaLink.ConfigurationDatabase.Migrations .IsDescending() .HasDatabaseName("IX_AuditLog_OccurredAtUtc"); + b.HasIndex("ParentExecutionId") + .HasDatabaseName("IX_AuditLog_ParentExecution") + .HasFilter("[ParentExecutionId] IS NOT NULL"); + b.HasIndex("SourceSiteId", "OccurredAtUtc") .IsDescending(false, true) .HasDatabaseName("IX_AuditLog_Site_Occurred"); diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs index 25d44c0..2c5cabf 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs @@ -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, ExecutionId, + (EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId, ExecutionId, ParentExecutionId, 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.ExecutionId}, + ({evt.EventId}, {evt.OccurredAtUtc}, {evt.IngestedAtUtc}, {channel}, {kind}, {evt.CorrelationId}, {evt.ExecutionId}, {evt.ParentExecutionId}, {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});", @@ -162,6 +162,11 @@ VALUES query = query.Where(e => e.ExecutionId == executionId); } + if (filter.ParentExecutionId is { } parentExecutionId) + { + query = query.Where(e => e.ParentExecutionId == parentExecutionId); + } + if (filter.FromUtc is { } fromUtc) { query = query.Where(e => e.OccurredAtUtc >= fromUtc); @@ -268,10 +273,13 @@ 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 and ParentExecutionId are last (in this ordinal order) + -- because each 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, + ParentExecutionId uniqueidentifier NULL, CONSTRAINT PK_{stagingTableName} PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc) ) ON [PRIMARY]; diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs index 6d9b52e..0b0fa0f 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs @@ -74,9 +74,10 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable .Where(p => !p.IsShadowProperty()) .ToList(); - // AuditEvent record exposes 22 init-only properties (alog.md §4 plus the - // additive ExecutionId universal correlation column). - Assert.Equal(22, properties.Count); + // AuditEvent record exposes 23 init-only properties (alog.md §4 plus the + // additive ExecutionId universal correlation column and its + // ParentExecutionId sibling). + Assert.Equal(23, properties.Count); } [Fact] @@ -92,13 +93,15 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable // Five reconciliation/query indexes from alog.md §4, plus the EventId unique // index introduced alongside the composite PK (Bundle C), plus the additive - // IX_AuditLog_Execution index supporting ExecutionId lookups. + // IX_AuditLog_Execution index supporting ExecutionId lookups and the + // IX_AuditLog_ParentExecution index supporting ParentExecutionId lookups. var expected = new[] { "IX_AuditLog_Channel_Status_Occurred", "IX_AuditLog_CorrelationId", "IX_AuditLog_Execution", "IX_AuditLog_OccurredAtUtc", + "IX_AuditLog_ParentExecution", "IX_AuditLog_Site_Occurred", "IX_AuditLog_Target_Occurred", "UX_AuditLog_EventId", @@ -143,5 +146,9 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable var executionIdx = entity.GetIndexes() .Single(i => i.GetDatabaseName() == "IX_AuditLog_Execution"); Assert.Equal("[ExecutionId] IS NOT NULL", executionIdx.GetFilter()); + + var parentExecutionIdx = entity.GetIndexes() + .Single(i => i.GetDatabaseName() == "IX_AuditLog_ParentExecution"); + Assert.Equal("[ParentExecutionId] IS NOT NULL", parentExecutionIdx.GetFilter()); } } diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs index 646c7df..01ab04d 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs @@ -275,6 +275,35 @@ public class AuditLogRepositoryTests : IClassFixture Assert.All(rows, r => Assert.Equal(executionId, r.ExecutionId)); } + [SkippableFact] + public async Task QueryAsync_FilterByParentExecutionId_ReturnsMatchingRows() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + var parentExecutionId = Guid.NewGuid(); + var t0 = new DateTime(2026, 5, 3, 13, 0, 0, DateTimeKind.Utc); + // Two rows share the ParentExecutionId; one carries a different + // ParentExecutionId and one leaves it null — both must be excluded by the + // single-value filter. + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, parentExecutionId: parentExecutionId)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), parentExecutionId: parentExecutionId)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), parentExecutionId: Guid.NewGuid())); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(3), parentExecutionId: null)); + + var rows = await repo.QueryAsync( + new AuditLogQueryFilter( + SourceSiteIds: new[] { siteId }, + ParentExecutionId: parentExecutionId), + new AuditLogPaging(PageSize: 10)); + + Assert.Equal(2, rows.Count); + Assert.All(rows, r => Assert.Equal(parentExecutionId, r.ParentExecutionId)); + } + [SkippableFact] public async Task QueryAsync_FilterByTimeRange() { @@ -754,7 +783,8 @@ public class AuditLogRepositoryTests : IClassFixture AuditKind kind = AuditKind.ApiCall, AuditStatus status = AuditStatus.Delivered, string? errorMessage = null, - Guid? executionId = null) => + Guid? executionId = null, + Guid? parentExecutionId = null) => new() { EventId = Guid.NewGuid(), @@ -765,5 +795,6 @@ public class AuditLogRepositoryTests : IClassFixture SourceSiteId = siteId, ErrorMessage = errorMessage, ExecutionId = executionId, + ParentExecutionId = parentExecutionId, }; } From 50430b9daa58fd72c164d23e2ac9904069e3d906 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 17:12:34 -0400 Subject: [PATCH 04/19] feat(auditlog): ParentExecutionId on site SQLite schema + gRPC AuditEventDto --- .../Site/SqliteAuditWriter.cs | 22 ++- .../Grpc/AuditEventDtoMapper.cs | 2 + .../Protos/sitestream.proto | 1 + .../SiteStreamGrpc/Sitestream.cs | 117 ++++++++++----- .../Site/SqliteAuditWriterSchemaTests.cs | 140 +++++++++++++++++- .../AuditEventDtoMapperTests.cs | 6 + 6 files changed, 241 insertions(+), 47 deletions(-) diff --git a/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs b/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs index 72c69d0..f38d99f 100644 --- a/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs +++ b/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs @@ -115,6 +115,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable Extra TEXT NULL, ForwardState TEXT NOT NULL, ExecutionId TEXT NULL, + ParentExecutionId TEXT NULL, PRIMARY KEY (EventId) ); CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred @@ -135,6 +136,14 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable // nullable with no default, so any row written before this migration // reads back ExecutionId = null (back-compat). AddColumnIfMissing("ExecutionId", "TEXT NULL"); + + // Audit Log #23 (ParentExecutionId): same idempotent upgrade path as + // ExecutionId above. A deployment that already ran the ExecutionId + // branch has an auditlog.db with the 21-column schema and no + // ParentExecutionId column; CREATE TABLE IF NOT EXISTS cannot add it, + // so it is ALTER-ed in here. Nullable with no default — rows written + // before this migration read back ParentExecutionId = null. + AddColumnIfMissing("ParentExecutionId", "TEXT NULL"); } /// @@ -263,13 +272,13 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState, - ExecutionId + ExecutionId, ParentExecutionId ) VALUES ( $EventId, $OccurredAtUtc, $Channel, $Kind, $CorrelationId, $SourceSiteId, $SourceInstanceId, $SourceScript, $Actor, $Target, $Status, $HttpStatus, $DurationMs, $ErrorMessage, $ErrorDetail, $RequestSummary, $ResponseSummary, $PayloadTruncated, $Extra, $ForwardState, - $ExecutionId + $ExecutionId, $ParentExecutionId ); """; @@ -294,6 +303,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable var pExtra = cmd.Parameters.Add("$Extra", SqliteType.Text); var pForwardState = cmd.Parameters.Add("$ForwardState", SqliteType.Text); var pExecutionId = cmd.Parameters.Add("$ExecutionId", SqliteType.Text); + var pParentExecutionId = cmd.Parameters.Add("$ParentExecutionId", SqliteType.Text); foreach (var pending in batch) { @@ -319,6 +329,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable pExtra.Value = (object?)e.Extra ?? DBNull.Value; pForwardState.Value = (e.ForwardState ?? AuditForwardState.Pending).ToString(); pExecutionId.Value = (object?)e.ExecutionId?.ToString() ?? DBNull.Value; + pParentExecutionId.Value = (object?)e.ParentExecutionId?.ToString() ?? DBNull.Value; try { @@ -377,7 +388,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState, - ExecutionId + ExecutionId, ParentExecutionId FROM AuditLog WHERE ForwardState = $pending ORDER BY OccurredAtUtc ASC, EventId ASC @@ -426,7 +437,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState, - ExecutionId + ExecutionId, ParentExecutionId FROM AuditLog WHERE ForwardState = $forwarded ORDER BY OccurredAtUtc ASC, EventId ASC @@ -513,7 +524,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState, - ExecutionId + ExecutionId, ParentExecutionId FROM AuditLog WHERE ForwardState IN ($pending, $forwarded) AND OccurredAtUtc >= $since @@ -691,6 +702,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable Extra = reader.IsDBNull(18) ? null : reader.GetString(18), ForwardState = Enum.Parse(reader.GetString(19)), ExecutionId = reader.IsDBNull(20) ? null : Guid.Parse(reader.GetString(20)), + ParentExecutionId = reader.IsDBNull(21) ? null : Guid.Parse(reader.GetString(21)), }; } diff --git a/src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs b/src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs index 4a679e7..640cb13 100644 --- a/src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs +++ b/src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs @@ -48,6 +48,7 @@ public static class AuditEventDtoMapper Kind = evt.Kind.ToString(), CorrelationId = evt.CorrelationId?.ToString() ?? string.Empty, ExecutionId = evt.ExecutionId?.ToString() ?? string.Empty, + ParentExecutionId = evt.ParentExecutionId?.ToString() ?? string.Empty, SourceSiteId = evt.SourceSiteId ?? string.Empty, SourceInstanceId = evt.SourceInstanceId ?? string.Empty, SourceScript = evt.SourceScript ?? string.Empty, @@ -94,6 +95,7 @@ public static class AuditEventDtoMapper Kind = Enum.Parse(dto.Kind), CorrelationId = NullIfEmpty(dto.CorrelationId) is { } cid ? Guid.Parse(cid) : null, ExecutionId = NullIfEmpty(dto.ExecutionId) is { } eid ? Guid.Parse(eid) : null, + ParentExecutionId = NullIfEmpty(dto.ParentExecutionId) is { } pid ? Guid.Parse(pid) : null, SourceSiteId = NullIfEmpty(dto.SourceSiteId), SourceInstanceId = NullIfEmpty(dto.SourceInstanceId), SourceScript = NullIfEmpty(dto.SourceScript), diff --git a/src/ScadaLink.Communication/Protos/sitestream.proto b/src/ScadaLink.Communication/Protos/sitestream.proto index 9c671e9..dccad81 100644 --- a/src/ScadaLink.Communication/Protos/sitestream.proto +++ b/src/ScadaLink.Communication/Protos/sitestream.proto @@ -92,6 +92,7 @@ message AuditEventDto { bool payload_truncated = 18; string extra = 19; string execution_id = 20; // empty string represents null + string parent_execution_id = 21; // empty string represents null } message AuditEventBatch { repeated AuditEventDto events = 1; } diff --git a/src/ScadaLink.Communication/SiteStreamGrpc/Sitestream.cs b/src/ScadaLink.Communication/SiteStreamGrpc/Sitestream.cs index d591e78..41e11e4 100644 --- a/src/ScadaLink.Communication/SiteStreamGrpc/Sitestream.cs +++ b/src/ScadaLink.Communication/SiteStreamGrpc/Sitestream.cs @@ -41,7 +41,7 @@ namespace ScadaLink.Communication.Grpc { "c3RhdGUYAyABKA4yGi5zaXRlc3RyZWFtLkFsYXJtU3RhdGVFbnVtEhAKCHBy", "aW9yaXR5GAQgASgFEi0KCXRpbWVzdGFtcBgFIAEoCzIaLmdvb2dsZS5wcm90", "b2J1Zi5UaW1lc3RhbXASKQoFbGV2ZWwYBiABKA4yGi5zaXRlc3RyZWFtLkFs", - "YXJtTGV2ZWxFbnVtEg8KB21lc3NhZ2UYByABKAkiiwQKDUF1ZGl0RXZlbnRE", + "YXJtTGV2ZWxFbnVtEg8KB21lc3NhZ2UYByABKAkiqAQKDUF1ZGl0RXZlbnRE", "dG8SEAoIZXZlbnRfaWQYASABKAkSMwoPb2NjdXJyZWRfYXRfdXRjGAIgASgL", "MhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIPCgdjaGFubmVsGAMgASgJ", "EgwKBGtpbmQYBCABKAkSFgoOY29ycmVsYXRpb25faWQYBSABKAkSFgoOc291", @@ -53,42 +53,43 @@ namespace ScadaLink.Communication.Grpc { "bWVzc2FnZRgOIAEoCRIUCgxlcnJvcl9kZXRhaWwYDyABKAkSFwoPcmVxdWVz", "dF9zdW1tYXJ5GBAgASgJEhgKEHJlc3BvbnNlX3N1bW1hcnkYESABKAkSGQoR", "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==")); + "Y3V0aW9uX2lkGBQgASgJEhsKE3BhcmVudF9leGVjdXRpb25faWQYFSABKAki", + "PAoPQXVkaXRFdmVudEJhdGNoEikKBmV2ZW50cxgBIAMoCzIZLnNpdGVzdHJl", + "YW0uQXVkaXRFdmVudER0byInCglJbmdlc3RBY2sSGgoSYWNjZXB0ZWRfZXZl", + "bnRfaWRzGAEgAygJIvQCChZTaXRlQ2FsbE9wZXJhdGlvbmFsRHRvEhwKFHRy", + "YWNrZWRfb3BlcmF0aW9uX2lkGAEgASgJEg8KB2NoYW5uZWwYAiABKAkSDgoG", + "dGFyZ2V0GAMgASgJEhMKC3NvdXJjZV9zaXRlGAQgASgJEg4KBnN0YXR1cxgF", + "IAEoCRITCgtyZXRyeV9jb3VudBgGIAEoBRISCgpsYXN0X2Vycm9yGAcgASgJ", + "EjAKC2h0dHBfc3RhdHVzGAggASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDMy", + "VmFsdWUSMgoOY3JlYXRlZF9hdF91dGMYCSABKAsyGi5nb29nbGUucHJvdG9i", + "dWYuVGltZXN0YW1wEjIKDnVwZGF0ZWRfYXRfdXRjGAogASgLMhouZ29vZ2xl", + "LnByb3RvYnVmLlRpbWVzdGFtcBIzCg90ZXJtaW5hbF9hdF91dGMYCyABKAsy", + "Gi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIoABChVDYWNoZWRUZWxlbWV0", + "cnlQYWNrZXQSLgoLYXVkaXRfZXZlbnQYASABKAsyGS5zaXRlc3RyZWFtLkF1", + "ZGl0RXZlbnREdG8SNwoLb3BlcmF0aW9uYWwYAiABKAsyIi5zaXRlc3RyZWFt", + "LlNpdGVDYWxsT3BlcmF0aW9uYWxEdG8iSgoUQ2FjaGVkVGVsZW1ldHJ5QmF0", + "Y2gSMgoHcGFja2V0cxgBIAMoCzIhLnNpdGVzdHJlYW0uQ2FjaGVkVGVsZW1l", + "dHJ5UGFja2V0IlsKFlB1bGxBdWRpdEV2ZW50c1JlcXVlc3QSLQoJc2luY2Vf", + "dXRjGAEgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBISCgpiYXRj", + "aF9zaXplGAIgASgFIlwKF1B1bGxBdWRpdEV2ZW50c1Jlc3BvbnNlEikKBmV2", + "ZW50cxgBIAMoCzIZLnNpdGVzdHJlYW0uQXVkaXRFdmVudER0bxIWCg5tb3Jl", + "X2F2YWlsYWJsZRgCIAEoCCpcCgdRdWFsaXR5EhcKE1FVQUxJVFlfVU5TUEVD", + "SUZJRUQQABIQCgxRVUFMSVRZX0dPT0QQARIVChFRVUFMSVRZX1VOQ0VSVEFJ", + "ThACEg8KC1FVQUxJVFlfQkFEEAMqXQoOQWxhcm1TdGF0ZUVudW0SGwoXQUxB", + "Uk1fU1RBVEVfVU5TUEVDSUZJRUQQABIWChJBTEFSTV9TVEFURV9OT1JNQUwQ", + "ARIWChJBTEFSTV9TVEFURV9BQ1RJVkUQAiqFAQoOQWxhcm1MZXZlbEVudW0S", + "FAoQQUxBUk1fTEVWRUxfTk9ORRAAEhMKD0FMQVJNX0xFVkVMX0xPVxABEhcK", + "E0FMQVJNX0xFVkVMX0xPV19MT1cQAhIUChBBTEFSTV9MRVZFTF9ISUdIEAMS", + "GQoVQUxBUk1fTEVWRUxfSElHSF9ISUdIEAQy4QIKEVNpdGVTdHJlYW1TZXJ2", + "aWNlElUKEVN1YnNjcmliZUluc3RhbmNlEiEuc2l0ZXN0cmVhbS5JbnN0YW5j", + "ZVN0cmVhbVJlcXVlc3QaGy5zaXRlc3RyZWFtLlNpdGVTdHJlYW1FdmVudDAB", + "EkcKEUluZ2VzdEF1ZGl0RXZlbnRzEhsuc2l0ZXN0cmVhbS5BdWRpdEV2ZW50", + "QmF0Y2gaFS5zaXRlc3RyZWFtLkluZ2VzdEFjaxJQChVJbmdlc3RDYWNoZWRU", + "ZWxlbWV0cnkSIC5zaXRlc3RyZWFtLkNhY2hlZFRlbGVtZXRyeUJhdGNoGhUu", + "c2l0ZXN0cmVhbS5Jbmdlc3RBY2sSWgoPUHVsbEF1ZGl0RXZlbnRzEiIuc2l0", + "ZXN0cmVhbS5QdWxsQXVkaXRFdmVudHNSZXF1ZXN0GiMuc2l0ZXN0cmVhbS5Q", + "dWxsQXVkaXRFdmVudHNSZXNwb25zZUIfqgIcU2NhZGFMaW5rLkNvbW11bmlj", + "YXRpb24uR3JwY2IGcHJvdG8z")); 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 +97,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", "ExecutionId" }, 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", "ParentExecutionId" }, 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), @@ -1592,6 +1593,7 @@ namespace ScadaLink.Communication.Grpc { payloadTruncated_ = other.payloadTruncated_; extra_ = other.extra_; executionId_ = other.executionId_; + parentExecutionId_ = other.parentExecutionId_; _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); } @@ -1854,6 +1856,21 @@ namespace ScadaLink.Communication.Grpc { } } + /// Field number for the "parent_execution_id" field. + public const int ParentExecutionIdFieldNumber = 21; + private string parentExecutionId_ = ""; + /// + /// empty string represents null + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string ParentExecutionId { + get { return parentExecutionId_; } + set { + parentExecutionId_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override bool Equals(object other) { @@ -1889,6 +1906,7 @@ namespace ScadaLink.Communication.Grpc { if (PayloadTruncated != other.PayloadTruncated) return false; if (Extra != other.Extra) return false; if (ExecutionId != other.ExecutionId) return false; + if (ParentExecutionId != other.ParentExecutionId) return false; return Equals(_unknownFields, other._unknownFields); } @@ -1916,6 +1934,7 @@ namespace ScadaLink.Communication.Grpc { if (PayloadTruncated != false) hash ^= PayloadTruncated.GetHashCode(); if (Extra.Length != 0) hash ^= Extra.GetHashCode(); if (ExecutionId.Length != 0) hash ^= ExecutionId.GetHashCode(); + if (ParentExecutionId.Length != 0) hash ^= ParentExecutionId.GetHashCode(); if (_unknownFields != null) { hash ^= _unknownFields.GetHashCode(); } @@ -2012,6 +2031,10 @@ namespace ScadaLink.Communication.Grpc { output.WriteRawTag(162, 1); output.WriteString(ExecutionId); } + if (ParentExecutionId.Length != 0) { + output.WriteRawTag(170, 1); + output.WriteString(ParentExecutionId); + } if (_unknownFields != null) { _unknownFields.WriteTo(output); } @@ -2100,6 +2123,10 @@ namespace ScadaLink.Communication.Grpc { output.WriteRawTag(162, 1); output.WriteString(ExecutionId); } + if (ParentExecutionId.Length != 0) { + output.WriteRawTag(170, 1); + output.WriteString(ParentExecutionId); + } if (_unknownFields != null) { _unknownFields.WriteTo(ref output); } @@ -2170,6 +2197,9 @@ namespace ScadaLink.Communication.Grpc { if (ExecutionId.Length != 0) { size += 2 + pb::CodedOutputStream.ComputeStringSize(ExecutionId); } + if (ParentExecutionId.Length != 0) { + size += 2 + pb::CodedOutputStream.ComputeStringSize(ParentExecutionId); + } if (_unknownFields != null) { size += _unknownFields.CalculateSize(); } @@ -2249,6 +2279,9 @@ namespace ScadaLink.Communication.Grpc { if (other.ExecutionId.Length != 0) { ExecutionId = other.ExecutionId; } + if (other.ParentExecutionId.Length != 0) { + ParentExecutionId = other.ParentExecutionId; + } _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); } @@ -2357,6 +2390,10 @@ namespace ScadaLink.Communication.Grpc { ExecutionId = input.ReadString(); break; } + case 170: { + ParentExecutionId = input.ReadString(); + break; + } } } #endif @@ -2465,6 +2502,10 @@ namespace ScadaLink.Communication.Grpc { ExecutionId = input.ReadString(); break; } + case 170: { + ParentExecutionId = input.ReadString(); + break; + } } } } diff --git a/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs index 13120e3..8f40ebe 100644 --- a/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs @@ -43,9 +43,9 @@ public class SqliteAuditWriterSchemaTests } [Fact] - public void Opens_Creates_AuditLog_Table_With_21Columns_And_PK_On_EventId() + public void Opens_Creates_AuditLog_Table_With_22Columns_And_PK_On_EventId() { - var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_AuditLog_Table_With_21Columns_And_PK_On_EventId)); + var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_AuditLog_Table_With_22Columns_And_PK_On_EventId)); using (writer) { using var connection = OpenVerifierConnection(dataSource); @@ -59,7 +59,7 @@ public class SqliteAuditWriterSchemaTests columns.Add((reader.GetString(1), reader.GetInt32(5))); } - Assert.Equal(21, columns.Count); + Assert.Equal(22, columns.Count); var expected = new[] { @@ -67,7 +67,7 @@ public class SqliteAuditWriterSchemaTests "SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target", "Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra", - "ForwardState", "ExecutionId", + "ForwardState", "ExecutionId", "ParentExecutionId", }; Assert.Equal(expected.OrderBy(n => n), columns.Select(c => c.Name).OrderBy(n => n)); @@ -245,4 +245,136 @@ public class SqliteAuditWriterSchemaTests Assert.True(ColumnExists(seedConnection, "ExecutionId")); } } + + // ----- ParentExecutionId schema-upgrade regression (persistent auditlog.db) ----- // + + /// + /// The pre-ParentExecutionId-branch AuditLog schema — the 21-column + /// CREATE TABLE that HAS ExecutionId but is WITHOUT + /// ParentExecutionId. A deployment that ran the ExecutionId branch + /// already has an on-disk auditlog.db in exactly this shape, and + /// CREATE TABLE IF NOT EXISTS is a no-op against it. + /// + private const string OldPreParentExecutionIdSchema = """ + 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, + ExecutionId TEXT NULL, + PRIMARY KEY (EventId) + ); + CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred + ON AuditLog (ForwardState, OccurredAtUtc); + """; + + /// + /// Seeds a shared-cache in-memory database with the pre-ParentExecutionId + /// 21-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. + /// + private static SqliteConnection SeedPreParentExecutionIdSchemaDatabase(string dataSource) + { + var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared"); + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = OldPreParentExecutionIdSchema; + cmd.ExecuteNonQuery(); + return connection; + } + + [Fact] + public async Task Opening_Over_PreExisting_PreParentExecutionId_Db_Adds_ParentExecutionId_Column_And_WriteAsync_RoundTrips() + { + var dataSource = $"file:{nameof(Opening_Over_PreExisting_PreParentExecutionId_Db_Adds_ParentExecutionId_Column_And_WriteAsync_RoundTrips)}-{Guid.NewGuid():N}?mode=memory&cache=shared"; + + // A deployment that ran the ExecutionId branch: auditlog.db already + // exists with the 21-column schema and NO ParentExecutionId column. + using var seedConnection = SeedPreParentExecutionIdSchemaDatabase(dataSource); + Assert.True(ColumnExists(seedConnection, "ExecutionId")); + Assert.False(ColumnExists(seedConnection, "ParentExecutionId")); + + // Upgrade: a post-branch SqliteAuditWriter opens the same database. Its + // InitializeSchema must ALTER the missing ParentExecutionId column in — + // the CREATE TABLE IF NOT EXISTS alone is a no-op against the existing + // table. + var executionId = Guid.NewGuid(); + var parentExecutionId = Guid.NewGuid(); + await using (var writer = CreateWriterOver(dataSource)) + { + Assert.True( + ColumnExists(seedConnection, "ParentExecutionId"), + "SqliteAuditWriter must ALTER the ParentExecutionId column into a pre-existing AuditLog table."); + + // A WriteAsync binding $ParentExecutionId must now succeed and + // round-trip; without the ALTER it would fail with "no such column: + // ParentExecutionId" 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, + ParentExecutionId = parentExecutionId, + }; + await writer.WriteAsync(evt); + + var rows = await writer.ReadPendingAsync(limit: 10); + var row = Assert.Single(rows); + Assert.Equal(executionId, row.ExecutionId); + Assert.Equal(parentExecutionId, row.ParentExecutionId); + } + + // Idempotency: a second writer over the now-upgraded DB must not error + // (the probe sees ParentExecutionId already present and skips the ALTER). + await using (var writerAgain = CreateWriterOver(dataSource)) + { + Assert.True(ColumnExists(seedConnection, "ParentExecutionId")); + } + } + + [Fact] + public async Task WriteAsync_NullParentExecutionId_RoundTripsAsNull() + { + var (writer, _) = CreateWriter(nameof(WriteAsync_NullParentExecutionId_RoundTripsAsNull)); + await using (writer) + { + var evt = new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTime.UtcNow, + Channel = AuditChannel.Notification, + Kind = AuditKind.NotifySend, + Status = AuditStatus.Submitted, + PayloadTruncated = false, + // ParentExecutionId left null + }; + await writer.WriteAsync(evt); + + var rows = await writer.ReadPendingAsync(limit: 10); + var row = Assert.Single(rows); + Assert.Null(row.ParentExecutionId); + } + } } diff --git a/tests/ScadaLink.Communication.Tests/AuditEventDtoMapperTests.cs b/tests/ScadaLink.Communication.Tests/AuditEventDtoMapperTests.cs index c741855..06d1239 100644 --- a/tests/ScadaLink.Communication.Tests/AuditEventDtoMapperTests.cs +++ b/tests/ScadaLink.Communication.Tests/AuditEventDtoMapperTests.cs @@ -20,6 +20,7 @@ public class AuditEventDtoMapperTests var ingestedAt = new DateTime(2026, 5, 20, 10, 15, 31, 0, DateTimeKind.Utc); var correlationId = Guid.NewGuid(); var executionId = Guid.NewGuid(); + var parentExecutionId = Guid.NewGuid(); var eventId = Guid.NewGuid(); var original = new AuditEvent @@ -31,6 +32,7 @@ public class AuditEventDtoMapperTests Kind = AuditKind.ApiCallCached, CorrelationId = correlationId, ExecutionId = executionId, + ParentExecutionId = parentExecutionId, SourceSiteId = "site-1", SourceInstanceId = "Pump01", SourceScript = "OnDemand", @@ -57,6 +59,7 @@ public class AuditEventDtoMapperTests Assert.Equal(original.Kind, roundTripped.Kind); Assert.Equal(original.CorrelationId, roundTripped.CorrelationId); Assert.Equal(original.ExecutionId, roundTripped.ExecutionId); + Assert.Equal(original.ParentExecutionId, roundTripped.ParentExecutionId); Assert.Equal(original.SourceSiteId, roundTripped.SourceSiteId); Assert.Equal(original.SourceInstanceId, roundTripped.SourceInstanceId); Assert.Equal(original.SourceScript, roundTripped.SourceScript); @@ -94,6 +97,7 @@ public class AuditEventDtoMapperTests Assert.Equal(string.Empty, dto.CorrelationId); Assert.Equal(string.Empty, dto.ExecutionId); + Assert.Equal(string.Empty, dto.ParentExecutionId); Assert.Equal(string.Empty, dto.SourceSiteId); Assert.Equal(string.Empty, dto.SourceInstanceId); Assert.Equal(string.Empty, dto.SourceScript); @@ -118,6 +122,7 @@ public class AuditEventDtoMapperTests Status = nameof(AuditStatus.Submitted), CorrelationId = string.Empty, ExecutionId = string.Empty, + ParentExecutionId = string.Empty, SourceSiteId = string.Empty, SourceInstanceId = string.Empty, SourceScript = string.Empty, @@ -134,6 +139,7 @@ public class AuditEventDtoMapperTests Assert.Null(evt.CorrelationId); Assert.Null(evt.ExecutionId); + Assert.Null(evt.ParentExecutionId); Assert.Null(evt.SourceSiteId); Assert.Null(evt.SourceInstanceId); Assert.Null(evt.SourceScript); From d8453bfba2c4b58e43d06a7a3049b0ad4649b3fb Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 17:22:13 -0400 Subject: [PATCH 05/19] feat(inboundapi): mint inbound ExecutionId early, carry it as RouteToCallRequest.ParentExecutionId --- .../InboundApi/RouteToInstanceRequest.cs | 11 ++- .../EndpointExtensions.cs | 15 +++- .../InboundScriptExecutor.cs | 22 +++++- .../Middleware/AuditWriteMiddleware.cs | 60 +++++++++++++--- src/ScadaLink.InboundAPI/RouteHelper.cs | 36 ++++++++-- .../InboundScriptExecutorTests.cs | 66 +++++++++++++++++ .../Middleware/AuditWriteMiddlewareTests.cs | 72 +++++++++++++++++++ .../RouteHelperTests.cs | 65 +++++++++++++++++ 8 files changed, 326 insertions(+), 21 deletions(-) diff --git a/src/ScadaLink.Commons/Messages/InboundApi/RouteToInstanceRequest.cs b/src/ScadaLink.Commons/Messages/InboundApi/RouteToInstanceRequest.cs index 1a020b4..90bc6f1 100644 --- a/src/ScadaLink.Commons/Messages/InboundApi/RouteToInstanceRequest.cs +++ b/src/ScadaLink.Commons/Messages/InboundApi/RouteToInstanceRequest.cs @@ -4,12 +4,21 @@ namespace ScadaLink.Commons.Messages.InboundApi; /// Request routed from Inbound API to a site to invoke a script on an instance. /// Used by Route.To("instanceCode").Call("scriptName", params). /// +/// +/// Audit Log #23 (ParentExecutionId): the spawning execution's ExecutionId +/// — for an inbound-API-routed call this is the inbound request's per-request +/// execution id. The site records it as the routed script execution's +/// ParentExecutionId so a spawned execution points back at its spawner. +/// Additive trailing member — null for requests built before the field existed +/// or for routed calls with no spawning execution (e.g. the Central UI sandbox). +/// public record RouteToCallRequest( string CorrelationId, string InstanceUniqueName, string ScriptName, IReadOnlyDictionary? Parameters, - DateTimeOffset Timestamp); + DateTimeOffset Timestamp, + Guid? ParentExecutionId = null); /// /// Response from a Route.To() call. diff --git a/src/ScadaLink.InboundAPI/EndpointExtensions.cs b/src/ScadaLink.InboundAPI/EndpointExtensions.cs index 4daec2c..3319800 100644 --- a/src/ScadaLink.InboundAPI/EndpointExtensions.cs +++ b/src/ScadaLink.InboundAPI/EndpointExtensions.cs @@ -92,8 +92,21 @@ public static class EndpointExtensions ? TimeSpan.FromSeconds(method.TimeoutSeconds) : options.DefaultMethodTimeout; + // Audit Log #23 (ParentExecutionId): the inbound request's per-request + // ExecutionId was minted early by AuditWriteMiddleware and stashed on + // HttpContext.Items. Thread it into the executor so a routed + // Route.To(...).Call(...) carries it as RouteToCallRequest.ParentExecutionId + // — the spawned site script execution points back at this inbound request. + var parentExecutionId = + httpContext.Items.TryGetValue( + AuditWriteMiddleware.InboundExecutionIdItemKey, out var stashedExecutionId) + && stashedExecutionId is Guid inboundExecutionId + ? inboundExecutionId + : (Guid?)null; + var scriptResult = await executor.ExecuteAsync( - method, paramResult.Parameters, routeHelper, timeout, httpContext.RequestAborted); + method, paramResult.Parameters, routeHelper, timeout, + httpContext.RequestAborted, parentExecutionId); if (!scriptResult.Success) { diff --git a/src/ScadaLink.InboundAPI/InboundScriptExecutor.cs b/src/ScadaLink.InboundAPI/InboundScriptExecutor.cs index ad969f2..2a68abe 100644 --- a/src/ScadaLink.InboundAPI/InboundScriptExecutor.cs +++ b/src/ScadaLink.InboundAPI/InboundScriptExecutor.cs @@ -4,6 +4,7 @@ using Microsoft.CodeAnalysis.CSharp.Scripting; using Microsoft.CodeAnalysis.Scripting; using Microsoft.Extensions.Logging; using ScadaLink.Commons.Entities.InboundApi; +using ScadaLink.Commons.Messages.InboundApi; using ScadaLink.Commons.Types; namespace ScadaLink.InboundAPI; @@ -156,12 +157,22 @@ public class InboundScriptExecutor /// /// Executes the script for the given method with the provided context. /// + /// + /// Audit Log #23 (ParentExecutionId): the inbound API request's per-request + /// ExecutionId (minted early by AuditWriteMiddleware and stashed + /// on HttpContext.Items). When supplied, a routed + /// Route.To(...).Call(...) inside the script carries it as + /// so the spawned site + /// script execution points back at this inbound request. Null when the script + /// runs outside an inbound API request flow. + /// public async Task ExecuteAsync( ApiMethod method, IReadOnlyDictionary parameters, RouteHelper route, TimeSpan timeout, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + Guid? parentExecutionId = null) { // InboundAPI-004: keep the timeout source and the request-abort source // separable. A single linked CTS makes a genuine client disconnect @@ -177,7 +188,14 @@ public class InboundScriptExecutor // InboundAPI-016: bind the route helper to the method deadline so a // routed Route.To(...).Call(...) inherits the method-level timeout // without the script having to thread the context token by hand. - var context = new InboundScriptContext(parameters, route.WithDeadline(cts.Token), cts.Token); + // + // Audit Log #23 (ParentExecutionId): also bind the inbound request's + // ExecutionId so a routed call carries it as ParentExecutionId — the + // spawned site script execution points back at this inbound request. + var context = new InboundScriptContext( + parameters, + route.WithDeadline(cts.Token).WithParentExecutionId(parentExecutionId), + cts.Token); if (!_scriptHandlers.TryGetValue(method.Name, out var handler)) { diff --git a/src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs b/src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs index b4b8410..71ce437 100644 --- a/src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs +++ b/src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs @@ -59,6 +59,18 @@ public sealed class AuditWriteMiddleware /// public const string AuditActorItemKey = "ScadaLink.InboundAPI.AuditActor"; + /// + /// Audit Log #23 (ParentExecutionId): key under + /// which this middleware stashes the inbound request's per-request + /// ExecutionId (a ) at the very start of the request. + /// The id is minted ONCE and shared: the endpoint handler reads it to thread it + /// onto a routed RouteToCallRequest.ParentExecutionId, and the + /// middleware's own inbound audit row uses the same id for its + /// . Exposed as a constant so the handler + /// and middleware share a single source of truth (no stringly-typed coupling). + /// + public const string InboundExecutionIdItemKey = "ScadaLink.InboundAPI.InboundExecutionId"; + private readonly RequestDelegate _next; private readonly ICentralAuditWriter _auditWriter; private readonly ILogger _logger; @@ -77,6 +89,17 @@ public sealed class AuditWriteMiddleware { var sw = Stopwatch.StartNew(); + // Audit Log #23 (ParentExecutionId): mint the inbound request's per-request + // ExecutionId ONCE, here at the start of the request, and stash it on + // HttpContext.Items. Two consumers share this single id: + // (a) the endpoint handler reads it to thread onto a routed + // RouteToCallRequest.ParentExecutionId, so a spawned site script + // execution points back at this inbound request; + // (b) the inbound audit row this middleware emits uses it as its own + // ExecutionId (the row stays top-level — its ParentExecutionId is + // never set). + ctx.Items[InboundExecutionIdItemKey] = Guid.NewGuid(); + // Buffer the request body up front so we can both audit it and let the // downstream handler still parse it. EnableBuffering swaps the request // stream for a seekable wrapper that the framework rewinds at the end @@ -145,17 +168,14 @@ public sealed class AuditWriteMiddleware OccurredAtUtc = DateTime.UtcNow, Channel = AuditChannel.ApiInbound, Kind = kind, - // 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 - // 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. - ExecutionId = Guid.NewGuid(), + // Audit Log #23: the per-request execution id minted ONCE at the + // start of the request (InvokeAsync) and stashed on + // HttpContext.Items. The same id is threaded onto a routed + // RouteToCallRequest.ParentExecutionId by the endpoint handler, + // so an inbound request and the site script it routes to share + // one correlation point. This inbound row stays top-level — its + // own ParentExecutionId is never set (see below). + ExecutionId = ResolveInboundExecutionId(ctx), // 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. @@ -225,6 +245,24 @@ public sealed class AuditWriteMiddleware } } + /// + /// Audit Log #23 (ParentExecutionId): reads the inbound request's per-request + /// ExecutionId that minted and stashed on + /// under . + /// Falls back to a fresh id only if the slot is somehow absent — the inbound + /// audit row must always carry an execution id. + /// + private static Guid ResolveInboundExecutionId(HttpContext ctx) + { + if (ctx.Items.TryGetValue(InboundExecutionIdItemKey, out var stashed) + && stashed is Guid id) + { + return id; + } + + return Guid.NewGuid(); + } + /// /// Reads the API key name the endpoint handler stashed on /// after successful auth. Falls back to diff --git a/src/ScadaLink.InboundAPI/RouteHelper.cs b/src/ScadaLink.InboundAPI/RouteHelper.cs index dc83d63..6380d13 100644 --- a/src/ScadaLink.InboundAPI/RouteHelper.cs +++ b/src/ScadaLink.InboundAPI/RouteHelper.cs @@ -19,22 +19,25 @@ public class RouteHelper private readonly IInstanceLocator _instanceLocator; private readonly IInstanceRouter _instanceRouter; private readonly CancellationToken _deadlineToken; + private readonly Guid? _parentExecutionId; public RouteHelper( IInstanceLocator instanceLocator, IInstanceRouter instanceRouter) - : this(instanceLocator, instanceRouter, CancellationToken.None) + : this(instanceLocator, instanceRouter, CancellationToken.None, parentExecutionId: null) { } private RouteHelper( IInstanceLocator instanceLocator, IInstanceRouter instanceRouter, - CancellationToken deadlineToken) + CancellationToken deadlineToken, + Guid? parentExecutionId) { _instanceLocator = instanceLocator; _instanceRouter = instanceRouter; _deadlineToken = deadlineToken; + _parentExecutionId = parentExecutionId; } /// @@ -45,14 +48,27 @@ public class RouteHelper /// requires. /// public RouteHelper WithDeadline(CancellationToken deadlineToken) => - new(_instanceLocator, _instanceRouter, deadlineToken); + new(_instanceLocator, _instanceRouter, deadlineToken, _parentExecutionId); + + /// + /// Audit Log #23 (ParentExecutionId): returns a whose + /// routed requests carry + /// as . + /// For an inbound API request this is the inbound request's own per-request + /// execution id, so the routed site script records the inbound request as its + /// parent. calls this when it builds the + /// script context. + /// + public RouteHelper WithParentExecutionId(Guid? parentExecutionId) => + new(_instanceLocator, _instanceRouter, _deadlineToken, parentExecutionId); /// /// Creates a route target for the specified instance. /// public RouteTarget To(string instanceCode) { - return new RouteTarget(instanceCode, _instanceLocator, _instanceRouter, _deadlineToken); + return new RouteTarget( + instanceCode, _instanceLocator, _instanceRouter, _deadlineToken, _parentExecutionId); } } @@ -65,17 +81,20 @@ public class RouteTarget private readonly IInstanceLocator _instanceLocator; private readonly IInstanceRouter _instanceRouter; private readonly CancellationToken _deadlineToken; + private readonly Guid? _parentExecutionId; internal RouteTarget( string instanceCode, IInstanceLocator instanceLocator, IInstanceRouter instanceRouter, - CancellationToken deadlineToken) + CancellationToken deadlineToken, + Guid? parentExecutionId) { _instanceCode = instanceCode; _instanceLocator = instanceLocator; _instanceRouter = instanceRouter; _deadlineToken = deadlineToken; + _parentExecutionId = parentExecutionId; } /// @@ -96,8 +115,13 @@ public class RouteTarget var siteId = await ResolveSiteAsync(token); var correlationId = Guid.NewGuid().ToString(); + // Audit Log #23 (ParentExecutionId): stamp the spawning execution's id + // (the inbound API request's ExecutionId) so the routed site script + // records this call's parent. CorrelationId above is a separate concern + // — the per-operation lifecycle id, freshly minted per routed call. var request = new RouteToCallRequest( - correlationId, _instanceCode, scriptName, ScriptArgs.Normalize(parameters), DateTimeOffset.UtcNow); + correlationId, _instanceCode, scriptName, ScriptArgs.Normalize(parameters), + DateTimeOffset.UtcNow, _parentExecutionId); var response = await _instanceRouter.RouteToCallAsync(siteId, request, token); diff --git a/tests/ScadaLink.InboundAPI.Tests/InboundScriptExecutorTests.cs b/tests/ScadaLink.InboundAPI.Tests/InboundScriptExecutorTests.cs index 5fbc6ee..e8cbf85 100644 --- a/tests/ScadaLink.InboundAPI.Tests/InboundScriptExecutorTests.cs +++ b/tests/ScadaLink.InboundAPI.Tests/InboundScriptExecutorTests.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using ScadaLink.Commons.Entities.InboundApi; using ScadaLink.Commons.Interfaces.Services; +using ScadaLink.Commons.Messages.InboundApi; namespace ScadaLink.InboundAPI.Tests; @@ -427,6 +428,71 @@ public class InboundScriptExecutorTests Assert.Contains("whatever", result.ResultJson!); } + // --- Audit Log #23 (ParentExecutionId, T3): the inbound request's + // ExecutionId is threaded through ExecuteAsync onto routed calls --- + + [Fact] + public async Task ExecuteAsync_WithParentExecutionId_RoutedCallCarriesItAsParentExecutionId() + { + // The endpoint hands ExecuteAsync the inbound request's ExecutionId; a + // routed Route.To(...).Call(...) inside the script must stamp that id onto + // the RouteToCallRequest as ParentExecutionId. + var inboundExecutionId = Guid.NewGuid(); + + var locator = Substitute.For(); + locator.GetSiteIdForInstanceAsync("inst-1", Arg.Any()).Returns("SiteA"); + var router = Substitute.For(); + RouteToCallRequest? captured = null; + router.RouteToCallAsync("SiteA", Arg.Do(r => captured = r), Arg.Any()) + .Returns(ci => new RouteToCallResponse( + ((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow)); + var route = new RouteHelper(locator, router); + + var method = new ApiMethod("routes", "return 1;") { Id = 1, TimeoutSeconds = 10 }; + _executor.RegisterHandler("routes", async ctx => + { + await ctx.Route.To("inst-1").Call("doWork"); + return 1; + }); + + var result = await _executor.ExecuteAsync( + method, new Dictionary(), route, TimeSpan.FromSeconds(10), + parentExecutionId: inboundExecutionId); + + Assert.True(result.Success, result.ErrorMessage); + Assert.NotNull(captured); + Assert.Equal(inboundExecutionId, captured!.ParentExecutionId); + } + + [Fact] + public async Task ExecuteAsync_WithoutParentExecutionId_RoutedCallHasNullParentExecutionId() + { + // ExecuteAsync called without a parent execution id (the default) routes + // calls with ParentExecutionId null. + var locator = Substitute.For(); + locator.GetSiteIdForInstanceAsync("inst-1", Arg.Any()).Returns("SiteA"); + var router = Substitute.For(); + RouteToCallRequest? captured = null; + router.RouteToCallAsync("SiteA", Arg.Do(r => captured = r), Arg.Any()) + .Returns(ci => new RouteToCallResponse( + ((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow)); + var route = new RouteHelper(locator, router); + + var method = new ApiMethod("routes2", "return 1;") { Id = 1, TimeoutSeconds = 10 }; + _executor.RegisterHandler("routes2", async ctx => + { + await ctx.Route.To("inst-1").Call("doWork"); + return 1; + }); + + var result = await _executor.ExecuteAsync( + method, new Dictionary(), route, TimeSpan.FromSeconds(10)); + + Assert.True(result.Success, result.ErrorMessage); + Assert.NotNull(captured); + Assert.Null(captured!.ParentExecutionId); + } + private sealed class CompileLogCounter { public int CompilationFailures; diff --git a/tests/ScadaLink.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs b/tests/ScadaLink.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs index 436507f..a28a6f6 100644 --- a/tests/ScadaLink.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs +++ b/tests/ScadaLink.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs @@ -395,6 +395,78 @@ public class AuditWriteMiddlewareTests Assert.NotEqual(writer.Events[0].ExecutionId, writer.Events[1].ExecutionId); } + // --------------------------------------------------------------------- + // ParentExecutionId — Audit Log #23 (ParentExecutionId feature, T3): the + // inbound request's ExecutionId is minted ONCE, early, and stashed on + // HttpContext.Items so the endpoint handler can carry it onto the routed + // RouteToCallRequest as ParentExecutionId. The inbound row that the + // middleware itself emits stays top-level — its own ParentExecutionId is + // NEVER set. + // --------------------------------------------------------------------- + + [Fact] + public async Task InboundExecutionId_IsStashedOnHttpItems_BeforeEndpointRuns() + { + var writer = new RecordingAuditWriter(); + var ctx = BuildContext(); + object? observedDuringHandler = null; + var mw = CreateMiddleware(hc => + { + // The endpoint handler must be able to read the early-minted id — + // it is stashed before _next so a downstream reader sees it. + hc.Items.TryGetValue(AuditWriteMiddleware.InboundExecutionIdItemKey, out observedDuringHandler); + hc.Response.StatusCode = 200; + return Task.CompletedTask; + }, writer); + + await mw.InvokeAsync(ctx); + + var stashed = Assert.IsType(observedDuringHandler); + Assert.NotEqual(Guid.Empty, stashed); + } + + [Fact] + public async Task InboundRow_ExecutionId_Equals_TheEarlyMintedStashedId() + { + var writer = new RecordingAuditWriter(); + var ctx = BuildContext(); + Guid stashedDuringHandler = Guid.Empty; + var mw = CreateMiddleware(hc => + { + stashedDuringHandler = + (Guid)hc.Items[AuditWriteMiddleware.InboundExecutionIdItemKey]!; + hc.Response.StatusCode = 200; + return Task.CompletedTask; + }, writer); + + await mw.InvokeAsync(ctx); + + // The inbound audit row's ExecutionId must be the SAME id minted early + // and shared with the endpoint handler — not a second, late mint. + var evt = Assert.Single(writer.Events); + Assert.Equal(stashedDuringHandler, evt.ExecutionId); + } + + [Fact] + public async Task InboundRow_OwnParentExecutionId_StaysNull() + { + var writer = new RecordingAuditWriter(); + var ctx = BuildContext(); + var mw = CreateMiddleware(_ => + { + ctx.Response.StatusCode = 200; + return Task.CompletedTask; + }, writer); + + await mw.InvokeAsync(ctx); + + // The inbound request is itself top-level — only the spawn id flows + // OUT on RouteToCallRequest. The inbound row's own ParentExecutionId + // is never set. + var evt = Assert.Single(writer.Events); + Assert.Null(evt.ParentExecutionId); + } + [Fact] public async Task DurationMs_IsRecorded() { diff --git a/tests/ScadaLink.InboundAPI.Tests/RouteHelperTests.cs b/tests/ScadaLink.InboundAPI.Tests/RouteHelperTests.cs index 3feaaaf..8dabaa7 100644 --- a/tests/ScadaLink.InboundAPI.Tests/RouteHelperTests.cs +++ b/tests/ScadaLink.InboundAPI.Tests/RouteHelperTests.cs @@ -233,6 +233,71 @@ public class RouteHelperTests Assert.Equal(explicitCts.Token, seen); } + // --- Audit Log #23 (ParentExecutionId, T3): a routed call carries the + // inbound request's ExecutionId as RouteToCallRequest.ParentExecutionId --- + + [Fact] + public async Task Call_WithoutParentExecutionId_LeavesParentExecutionIdNull() + { + // A RouteHelper not bound to an inbound execution id (e.g. the Central UI + // sandbox path) builds requests with ParentExecutionId null. + SiteResolves("inst-1", "SiteA"); + RouteToCallRequest? captured = null; + _router.RouteToCallAsync("SiteA", Arg.Do(r => captured = r), Arg.Any()) + .Returns(ci => new RouteToCallResponse( + ((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow)); + + await CreateHelper().To("inst-1").Call("doWork"); + + Assert.NotNull(captured); + Assert.Null(captured!.ParentExecutionId); + } + + [Fact] + public async Task Call_WithParentExecutionId_CarriesItOnRouteToCallRequest() + { + // A RouteHelper bound to the inbound request's ExecutionId must stamp that + // id onto the routed RouteToCallRequest so the site script records it as + // its ParentExecutionId. + SiteResolves("inst-1", "SiteA"); + var inboundExecutionId = Guid.NewGuid(); + RouteToCallRequest? captured = null; + _router.RouteToCallAsync("SiteA", Arg.Do(r => captured = r), Arg.Any()) + .Returns(ci => new RouteToCallResponse( + ((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow)); + + var bound = CreateHelper().WithParentExecutionId(inboundExecutionId); + await bound.To("inst-1").Call("doWork"); + + Assert.NotNull(captured); + Assert.Equal(inboundExecutionId, captured!.ParentExecutionId); + // ParentExecutionId is a separate concern from the per-op CorrelationId — + // the helper still mints its own routed-call correlation id. + Assert.True(Guid.TryParse(captured.CorrelationId, out _)); + } + + [Fact] + public async Task WithParentExecutionId_PreservesDeadlineToken() + { + // The two builder methods compose — binding a parent execution id must + // not drop a previously-bound deadline token. + SiteResolves("inst-1", "SiteA"); + using var deadline = new CancellationTokenSource(); + CancellationToken seen = default; + RouteToCallRequest? captured = null; + _router.RouteToCallAsync("SiteA", Arg.Do(r => captured = r), Arg.Do(t => seen = t)) + .Returns(ci => new RouteToCallResponse( + ((RouteToCallRequest)ci[1]).CorrelationId, true, null, null, DateTimeOffset.UtcNow)); + + var bound = CreateHelper() + .WithDeadline(deadline.Token) + .WithParentExecutionId(Guid.NewGuid()); + await bound.To("inst-1").Call("doWork"); + + Assert.Equal(deadline.Token, seen); + Assert.NotNull(captured!.ParentExecutionId); + } + [Fact] public async Task GetAttributes_WithNoExplicitToken_InheritsMethodDeadlineToken() { From dc2c73b07d1664176f7bf41ff84dc03f61e2ced9 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 17:26:49 -0400 Subject: [PATCH 06/19] refactor(inboundapi): fail-fast on missing inbound ExecutionId stash --- src/ScadaLink.InboundAPI/InboundScriptExecutor.cs | 3 +++ .../Middleware/AuditWriteMiddleware.cs | 11 ++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/ScadaLink.InboundAPI/InboundScriptExecutor.cs b/src/ScadaLink.InboundAPI/InboundScriptExecutor.cs index 2a68abe..80aff65 100644 --- a/src/ScadaLink.InboundAPI/InboundScriptExecutor.cs +++ b/src/ScadaLink.InboundAPI/InboundScriptExecutor.cs @@ -172,6 +172,9 @@ public class InboundScriptExecutor RouteHelper route, TimeSpan timeout, CancellationToken cancellationToken = default, + // Deliberate ordering: this optional parameter trails the CancellationToken + // because it was appended additively for non-breaking contract evolution. + // Every call site passes it by named argument (parentExecutionId:). Guid? parentExecutionId = null) { // InboundAPI-004: keep the timeout source and the request-abort source diff --git a/src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs b/src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs index 71ce437..db83c85 100644 --- a/src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs +++ b/src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs @@ -249,8 +249,11 @@ public sealed class AuditWriteMiddleware /// Audit Log #23 (ParentExecutionId): reads the inbound request's per-request /// ExecutionId that minted and stashed on /// under . - /// Falls back to a fresh id only if the slot is somehow absent — the inbound - /// audit row must always carry an execution id. + /// Throws if the slot is absent — for a + /// correlation feature a silently-divergent id is the worst failure mode, so we + /// fail fast rather than mint a fresh one. 's + /// try/catch degrades the throw to a dropped best-effort audit row, never a + /// failed request. /// private static Guid ResolveInboundExecutionId(HttpContext ctx) { @@ -260,7 +263,9 @@ public sealed class AuditWriteMiddleware return id; } - return Guid.NewGuid(); + throw new InvalidOperationException( + "Inbound ExecutionId invariant violated: the inbound ExecutionId must be " + + "stashed by AuditWriteMiddleware.InvokeAsync before the audit row is emitted."); } /// From 6af2607a50aa2949312b5e9a0393fe13783b6d00 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 17:35:49 -0400 Subject: [PATCH 07/19] feat(siteruntime): thread ParentExecutionId into the routed script's ScriptRuntimeContext --- .../ScriptExecution/ScriptCallRequest.cs | 12 +- .../Actors/DeploymentManagerActor.cs | 8 +- .../Actors/InstanceActor.cs | 5 +- .../Actors/ScriptActor.cs | 16 ++- .../Actors/ScriptExecutionActor.cs | 19 +++- .../Scripts/ScriptRuntimeContext.cs | 105 ++++++++++++++++-- .../Actors/DeploymentManagerActorTests.cs | 76 +++++++++++++ .../ExecutionCorrelationContextTests.cs | 72 +++++++++++- 8 files changed, 288 insertions(+), 25 deletions(-) diff --git a/src/ScadaLink.Commons/Messages/ScriptExecution/ScriptCallRequest.cs b/src/ScadaLink.Commons/Messages/ScriptExecution/ScriptCallRequest.cs index 5d06582..b8a7f41 100644 --- a/src/ScadaLink.Commons/Messages/ScriptExecution/ScriptCallRequest.cs +++ b/src/ScadaLink.Commons/Messages/ScriptExecution/ScriptCallRequest.cs @@ -1,7 +1,17 @@ namespace ScadaLink.Commons.Messages.ScriptExecution; +/// +/// Audit Log #23 (ParentExecutionId): the spawning execution's ExecutionId. +/// For an inbound-API-routed call this is the inbound request's per-request +/// execution id (carried in from RouteToCallRequest.ParentExecutionId); +/// the routed script execution records it as its ParentExecutionId so a +/// spawned execution points back at its spawner. Additive trailing member — +/// null for normal (tag-change / timer-triggered) runs, nested Script.Call +/// invocations, and any request built before the field existed. +/// public record ScriptCallRequest( string ScriptName, IReadOnlyDictionary? Parameters, int CurrentCallDepth, - string CorrelationId); + string CorrelationId, + Guid? ParentExecutionId = null); diff --git a/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs b/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs index e28a32a..9eaf607 100644 --- a/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs @@ -735,9 +735,13 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers { if (_instanceActors.TryGetValue(request.InstanceUniqueName, out var instanceActor)) { - // Convert to ScriptCallRequest and Ask the Instance Actor + // Convert to ScriptCallRequest and Ask the Instance Actor. + // Audit Log #23 (ParentExecutionId): carry the inbound request's + // ExecutionId down as ParentExecutionId so the routed script + // execution can record its spawner. var scriptCall = new ScriptCallRequest( - request.ScriptName, request.Parameters, 0, request.CorrelationId); + request.ScriptName, request.Parameters, 0, request.CorrelationId, + ParentExecutionId: request.ParentExecutionId); var sender = Sender; instanceActor.Ask(scriptCall, TimeSpan.FromSeconds(30)) .ContinueWith(t => diff --git a/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs b/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs index 1b3c80b..9aeef36 100644 --- a/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs @@ -320,7 +320,10 @@ public class InstanceActor : ReceiveActor { if (_scriptActors.TryGetValue(request.ScriptName, out var scriptActor)) { - // Forward the request to the Script Actor, preserving the original sender + // Forward the request to the Script Actor, preserving the original + // sender. The whole record is forwarded unchanged, so any + // ParentExecutionId (Audit Log #23) set by an inbound-API-routed + // call is carried through to the Script Actor verbatim. scriptActor.Forward(request); } else diff --git a/src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs b/src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs index 9cf6928..0fd5efa 100644 --- a/src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs @@ -184,7 +184,13 @@ public class ScriptActor : ReceiveActor, IWithTimers return; } - SpawnExecution(request.Parameters, request.CurrentCallDepth, Sender, request.CorrelationId); + // Audit Log #23 (ParentExecutionId): carry any inbound-routed + // ParentExecutionId through to the ScriptExecutionActor so the routed + // script's ScriptRuntimeContext can record its spawner. Null for normal + // (tag-change / timer) runs and nested Script.Call invocations. + SpawnExecution( + request.Parameters, request.CurrentCallDepth, Sender, request.CorrelationId, + request.ParentExecutionId); } /// @@ -379,7 +385,8 @@ public class ScriptActor : ReceiveActor, IWithTimers IReadOnlyDictionary? parameters, int callDepth, IActorRef replyTo, - string correlationId) + string correlationId, + Guid? parentExecutionId = null) { var executionId = $"{_scriptName}-exec-{_executionCounter++}"; @@ -401,7 +408,10 @@ public class ScriptActor : ReceiveActor, IWithTimers _logger, _scope, _healthCollector, - _serviceProvider)); + _serviceProvider, + // Audit Log #23 (ParentExecutionId): null for trigger-driven runs; + // an inbound-API-routed call supplies the inbound request's id. + parentExecutionId)); Context.ActorOf(props, executionId); } diff --git a/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs b/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs index e5b84ae..c0f517c 100644 --- a/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs +++ b/src/ScadaLink.SiteRuntime/Actors/ScriptExecutionActor.cs @@ -43,7 +43,11 @@ public class ScriptExecutionActor : ReceiveActor ILogger logger, Commons.Types.Scripts.ScriptScope scope, ISiteHealthCollector? healthCollector = null, - IServiceProvider? serviceProvider = null) + IServiceProvider? serviceProvider = null, + // Audit Log #23 (ParentExecutionId): the spawning execution's + // ExecutionId for an inbound-API-routed call. Null for normal + // (tag-change / timer) runs and nested Script.Call invocations. + Guid? parentExecutionId = null) { // Immediately begin execution var self = Self; @@ -52,7 +56,8 @@ public class ScriptExecutionActor : ReceiveActor ExecuteScript( scriptName, instanceName, compiledScript, parameters, callDepth, instanceActor, sharedScriptLibrary, options, replyTo, correlationId, - self, parent, logger, scope, healthCollector, serviceProvider); + self, parent, logger, scope, healthCollector, serviceProvider, + parentExecutionId); } private static void ExecuteScript( @@ -71,7 +76,8 @@ public class ScriptExecutionActor : ReceiveActor ILogger logger, Commons.Types.Scripts.ScriptScope scope, ISiteHealthCollector? healthCollector, - IServiceProvider? serviceProvider) + IServiceProvider? serviceProvider, + Guid? parentExecutionId) { var timeout = TimeSpan.FromSeconds(options.ScriptExecutionTimeoutSeconds); @@ -164,7 +170,12 @@ public class ScriptExecutionActor : ReceiveActor // emission. Best-effort: null degrades the helpers to a // no-emission path; the S&F handoff and TrackedOperationId // return are unaffected. - cachedForwarder: cachedForwarder); + cachedForwarder: cachedForwarder, + // Audit Log #23 (ParentExecutionId): the spawning execution's + // id for an inbound-API-routed call. The routed script still + // mints its own fresh ExecutionId — this records the spawner. + // Null for normal (tag-change / timer) runs. + parentExecutionId: parentExecutionId); var globals = new ScriptGlobals { diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs index 2b5df67..a112928 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs @@ -116,6 +116,19 @@ public class ScriptRuntimeContext /// private readonly Guid _executionId; + /// + /// Audit Log #23 (ParentExecutionId): the spawning execution's + /// when this script run was spawned by another + /// execution — for an inbound-API-routed call this is the inbound request's + /// per-request execution id. null for normal (tag-change / + /// timer-triggered) runs and nested CallScript invocations. The + /// routed script still mints its OWN fresh ; this + /// field records the spawner so a spawned execution's audit rows can point + /// back at the execution that spawned it. (Task 5 wires the emitter that + /// stamps this onto AuditEvent.ParentExecutionId.) + /// + private readonly Guid? _parentExecutionId; + /// /// Audit Log #23: the per-execution id for this script run. When omitted /// (tag-change / timer-triggered executions) a fresh id is generated; an @@ -123,6 +136,13 @@ public class ScriptRuntimeContext /// request. Stamped into AuditEvent.ExecutionId on every /// trust-boundary audit row this execution emits. /// + /// + /// Audit Log #23 (ParentExecutionId): the spawning execution's + /// ExecutionId — supplied for an inbound-API-routed call (the + /// inbound request's per-request id), null for normal (tag-change / + /// timer-triggered) runs. The routed script still generates its own fresh + /// ; this only records the spawner. + /// public ScriptRuntimeContext( IActorRef instanceActor, IActorRef self, @@ -141,7 +161,8 @@ public class ScriptRuntimeContext IAuditWriter? auditWriter = null, IOperationTrackingStore? operationTrackingStore = null, ICachedCallTelemetryForwarder? cachedForwarder = null, - Guid? executionId = null) + Guid? executionId = null, + Guid? parentExecutionId = null) { _instanceActor = instanceActor; _self = self; @@ -161,6 +182,9 @@ public class ScriptRuntimeContext _operationTrackingStore = operationTrackingStore; _cachedForwarder = cachedForwarder; _executionId = executionId ?? Guid.NewGuid(); + // Audit Log #23 (ParentExecutionId): stored verbatim — no `?? NewGuid()` + // fallback. A non-routed run legitimately has no parent and stays null. + _parentExecutionId = parentExecutionId; } /// @@ -264,7 +288,10 @@ public class ScriptRuntimeContext _externalSystemClient, _instanceName, _logger, _executionId, _auditWriter, _siteId, _sourceScript, // Audit Log #23 (M3 Bundle E — Task E3): emit CachedSubmit telemetry // on every ExternalSystem.CachedCall enqueue. - _cachedForwarder); + _cachedForwarder, + // Audit Log #23 (ParentExecutionId): the spawning execution's id, + // threaded alongside _executionId. Null for non-routed runs. + _parentExecutionId); /// /// WP-13: Provides access to database operations. @@ -285,7 +312,10 @@ public class ScriptRuntimeContext _sourceScript, // Audit Log #23 (M3 Bundle E — Task E6): emit CachedSubmit telemetry on // every Database.CachedWrite enqueue. - _cachedForwarder); + _cachedForwarder, + // Audit Log #23 (ParentExecutionId): the spawning execution's id, + // threaded alongside _executionId. Null for non-routed runs. + _parentExecutionId); /// /// Provides access to the Notification Outbox API. @@ -302,7 +332,10 @@ public class ScriptRuntimeContext /// public NotifyHelper Notify => new( _storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _sourceScript, _askTimeout, _logger, - _executionId, _auditWriter); + _executionId, _auditWriter, + // Audit Log #23 (ParentExecutionId): the spawning execution's id, + // threaded alongside _executionId. Null for non-routed runs. + _parentExecutionId); /// /// Audit Log #23 (M3): site-local tracking-status API for cached operations. @@ -384,6 +417,15 @@ public class ScriptRuntimeContext private readonly string _instanceName; private readonly ILogger _logger; private readonly Guid _executionId; + + /// + /// Audit Log #23 (ParentExecutionId): the spawning execution's id when + /// this run was inbound-API-routed; null for non-routed runs. + /// Threaded alongside ready for the Task 5 + /// emitter — no audit row carries it yet. + /// + private readonly Guid? _parentExecutionId; + private readonly IAuditWriter? _auditWriter; private readonly string _siteId; private readonly string? _sourceScript; @@ -398,7 +440,9 @@ public class ScriptRuntimeContext // DatabaseHelper, AuditingDbConnection, AuditingDbCommand) — a required // Guid cannot follow the optional provenance params without a // required-after-optional compile error, so the post-logger slot is the - // one consistent position that compiles cleanly everywhere. + // one consistent position that compiles cleanly everywhere. The nullable + // parentExecutionId is a trailing optional param so existing positional + // callers stay source-compatible. internal ExternalSystemHelper( IExternalSystemClient? client, string instanceName, @@ -407,7 +451,8 @@ public class ScriptRuntimeContext IAuditWriter? auditWriter = null, string siteId = "", string? sourceScript = null, - ICachedCallTelemetryForwarder? cachedForwarder = null) + ICachedCallTelemetryForwarder? cachedForwarder = null, + Guid? parentExecutionId = null) { _client = client; _instanceName = instanceName; @@ -417,6 +462,7 @@ public class ScriptRuntimeContext _siteId = siteId; _sourceScript = sourceScript; _cachedForwarder = cachedForwarder; + _parentExecutionId = parentExecutionId; } public async Task Call( @@ -1001,6 +1047,15 @@ public class ScriptRuntimeContext private readonly string _instanceName; private readonly ILogger _logger; private readonly Guid _executionId; + + /// + /// Audit Log #23 (ParentExecutionId): the spawning execution's id when + /// this run was inbound-API-routed; null for non-routed runs. + /// Threaded alongside ready for the Task 5 + /// emitter — no audit row carries it yet. + /// + private readonly Guid? _parentExecutionId; + private readonly string _siteId; private readonly string? _sourceScript; private readonly ICachedCallTelemetryForwarder? _cachedForwarder; @@ -1020,7 +1075,7 @@ public class ScriptRuntimeContext // 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. + // audit-threaded ctors. parentExecutionId is a trailing optional param. internal DatabaseHelper( IDatabaseGateway? gateway, string instanceName, @@ -1029,7 +1084,8 @@ public class ScriptRuntimeContext IAuditWriter? auditWriter = null, string siteId = "", string? sourceScript = null, - ICachedCallTelemetryForwarder? cachedForwarder = null) + ICachedCallTelemetryForwarder? cachedForwarder = null, + Guid? parentExecutionId = null) { _gateway = gateway; _instanceName = instanceName; @@ -1039,6 +1095,7 @@ public class ScriptRuntimeContext _siteId = siteId; _sourceScript = sourceScript; _cachedForwarder = cachedForwarder; + _parentExecutionId = parentExecutionId; } public async Task Connection( @@ -1213,6 +1270,14 @@ public class ScriptRuntimeContext /// private readonly Guid _executionId; + /// + /// Audit Log #23 (ParentExecutionId): the spawning execution's id when + /// this run was inbound-API-routed; null for non-routed runs. + /// Threaded alongside ready for the Task 5 + /// emitter — no audit row carries it yet. + /// + private readonly Guid? _parentExecutionId; + /// /// Audit Log #23 (M4 Bundle C): best-effort emitter for the /// Notification/NotifySend row produced when the script @@ -1224,7 +1289,8 @@ public class ScriptRuntimeContext private readonly IAuditWriter? _auditWriter; // Parameter ordering: executionId sits immediately after the ILogger, - // consistent with the other audit-threaded ctors. + // consistent with the other audit-threaded ctors. parentExecutionId is + // a trailing optional param. internal NotifyHelper( StoreAndForwardService? storeAndForward, ICanTell? siteCommunicationActor, @@ -1234,7 +1300,8 @@ public class ScriptRuntimeContext TimeSpan askTimeout, ILogger logger, Guid executionId, - IAuditWriter? auditWriter = null) + IAuditWriter? auditWriter = null, + Guid? parentExecutionId = null) { _storeAndForward = storeAndForward; _siteCommunicationActor = siteCommunicationActor; @@ -1245,6 +1312,7 @@ public class ScriptRuntimeContext _logger = logger; _executionId = executionId; _auditWriter = auditWriter; + _parentExecutionId = parentExecutionId; } /// @@ -1259,7 +1327,10 @@ public class ScriptRuntimeContext _executionId, // Audit Log #23 (M4 Bundle C): forward the writer so Send() // can emit one NotifySend(Submitted) row per accepted submission. - _auditWriter); + _auditWriter, + // Audit Log #23 (ParentExecutionId): the spawning execution's + // id, threaded alongside _executionId. Null for non-routed runs. + _parentExecutionId); } /// @@ -1340,6 +1411,14 @@ public class ScriptRuntimeContext /// private readonly Guid _executionId; + /// + /// Audit Log #23 (ParentExecutionId): the spawning execution's id when + /// this run was inbound-API-routed; null for non-routed runs. + /// Threaded alongside ready for the Task 5 + /// emitter — no audit row carries it yet. + /// + private readonly Guid? _parentExecutionId; + /// /// Audit Log #23 (M4 Bundle C): best-effort emitter for the /// Notification/NotifySend row written immediately after @@ -1356,7 +1435,8 @@ public class ScriptRuntimeContext string? sourceScript, ILogger logger, Guid executionId, - IAuditWriter? auditWriter = null) + IAuditWriter? auditWriter = null, + Guid? parentExecutionId = null) { _listName = listName; _storeAndForward = storeAndForward; @@ -1366,6 +1446,7 @@ public class ScriptRuntimeContext _logger = logger; _executionId = executionId; _auditWriter = auditWriter; + _parentExecutionId = parentExecutionId; } /// diff --git a/tests/ScadaLink.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs index f9c8513..fdfd952 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs @@ -3,6 +3,7 @@ using Akka.TestKit.Xunit2; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ScadaLink.Commons.Messages.Deployment; +using ScadaLink.Commons.Messages.InboundApi; using ScadaLink.Commons.Messages.Lifecycle; using ScadaLink.Commons.Types.Enums; using ScadaLink.Commons.Types.Flattening; @@ -68,6 +69,28 @@ public class DeploymentManagerActorTests : TestKit, IDisposable return JsonSerializer.Serialize(config); } + /// + /// Builds a config carrying a single callable (no-trigger) script that + /// returns a constant — enough for an inbound + /// to be routed end-to-end through the Instance/Script/ScriptExecution actors. + /// + private static string MakeConfigWithScriptJson(string instanceName, string scriptName) + { + var config = new FlattenedConfiguration + { + InstanceUniqueName = instanceName, + Attributes = + [ + new ResolvedAttribute { CanonicalName = "TestAttr", Value = "42", DataType = "Int32" } + ], + Scripts = + [ + new ResolvedScript { CanonicalName = scriptName, Code = "return 7;" } + ] + }; + return JsonSerializer.Serialize(config); + } + [Fact] public async Task DeploymentManager_CreatesInstanceActors_FromStoredConfigs() { @@ -240,4 +263,57 @@ public class DeploymentManagerActorTests : TestKit, IDisposable var response = ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal(DeploymentStatus.Success, response.Status); } + + // ── Audit Log #23 (ParentExecutionId, Task 4): inbound-API routing ── + + [Fact] + public async Task RouteInboundApiCall_WithParentExecutionId_RoutesToScriptSuccessfully() + { + // A RouteToCallRequest carrying a ParentExecutionId (the inbound + // request's ExecutionId) must be mapped to a ScriptCallRequest and + // routed end-to-end through the Instance/Script/ScriptExecution actors. + // The additive ParentExecutionId field must not break that routing. + var actor = CreateDeploymentManager(); + await Task.Delay(500); // empty startup + + actor.Tell(new DeployInstanceCommand( + "dep-route", "RoutedPump", "sha256:route", + MakeConfigWithScriptJson("RoutedPump", "DoWork"), "admin", DateTimeOffset.UtcNow)); + ExpectMsg(TimeSpan.FromSeconds(5)); + await Task.Delay(1000); // let the InstanceActor + ScriptActor spin up + + var parentExecutionId = Guid.NewGuid(); + actor.Tell(new RouteToCallRequest( + "route-corr-1", "RoutedPump", "DoWork", + Parameters: null, DateTimeOffset.UtcNow, ParentExecutionId: parentExecutionId)); + + var response = ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.Equal("route-corr-1", response.CorrelationId); + Assert.True(response.Success, $"Routed call failed: {response.ErrorMessage}"); + Assert.Equal(7, Convert.ToInt32(response.ReturnValue)); + } + + [Fact] + public async Task RouteInboundApiCall_WithoutParentExecutionId_StillRoutes() + { + // A routed call with no ParentExecutionId (e.g. the Central UI sandbox) + // is the additive-default path — it must route exactly as before. + var actor = CreateDeploymentManager(); + await Task.Delay(500); + + actor.Tell(new DeployInstanceCommand( + "dep-route2", "RoutedPump2", "sha256:route2", + MakeConfigWithScriptJson("RoutedPump2", "DoWork"), "admin", DateTimeOffset.UtcNow)); + ExpectMsg(TimeSpan.FromSeconds(5)); + await Task.Delay(1000); + + // No ParentExecutionId argument — exercises the additive `= null` default. + actor.Tell(new RouteToCallRequest( + "route-corr-2", "RoutedPump2", "DoWork", + Parameters: null, DateTimeOffset.UtcNow)); + + var response = ExpectMsg(TimeSpan.FromSeconds(10)); + Assert.Equal("route-corr-2", response.CorrelationId); + Assert.True(response.Success, $"Routed call failed: {response.ErrorMessage}"); + } } diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs index 5e85efb..280830d 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs @@ -62,7 +62,8 @@ public class ExecutionCorrelationContextTests IExternalSystemClient? externalSystemClient, IDatabaseGateway? databaseGateway, IAuditWriter? auditWriter, - Guid? executionId = null) + Guid? executionId = null, + Guid? parentExecutionId = null) { var compilationService = new ScriptCompilationService( NullLogger.Instance); @@ -87,7 +88,24 @@ public class ExecutionCorrelationContextTests auditWriter: auditWriter, operationTrackingStore: null, cachedForwarder: null, - executionId: executionId); + executionId: executionId, + parentExecutionId: parentExecutionId); + } + + /// + /// Reads a private / field off a + /// . The ParentExecutionId plumbing (Audit + /// Log #23, Task 4) only stores the value on the context — no emitter stamps + /// it onto an audit row yet (that is Task 5) — so the field is inspected + /// directly rather than through an emitted row. + /// + private static object? ReadPrivateField(ScriptRuntimeContext context, string fieldName) + { + var field = typeof(ScriptRuntimeContext).GetField( + fieldName, + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.NotNull(field); + return field!.GetValue(context); } /// @@ -183,4 +201,54 @@ public class ExecutionCorrelationContextTests Assert.Null(apiRow.CorrelationId); Assert.Null(dbRow.CorrelationId); } + + [Fact] + public void ParentExecutionIdSupplied_StoredVerbatim_AndOwnExecutionIdIsFreshAndDistinct() + { + // Audit Log #23 (ParentExecutionId, Task 4): an inbound-API-routed call + // supplies the spawning execution's ExecutionId as the routed script's + // ParentExecutionId. The context must store that value verbatim AND + // still mint its OWN fresh ExecutionId — the routed script is a new + // execution, it does not inherit the parent's id. + var parentExecutionId = Guid.NewGuid(); + + var context = CreateContext( + externalSystemClient: null, + databaseGateway: null, + auditWriter: null, + // executionId omitted — the ctor's `?? Guid.NewGuid()` fallback runs. + parentExecutionId: parentExecutionId); + + var storedParent = ReadPrivateField(context, "_parentExecutionId"); + var ownExecutionId = ReadPrivateField(context, "_executionId"); + + // The parent id is carried through untouched. + Assert.Equal(parentExecutionId, storedParent); + + // The routed script's own ExecutionId is freshly generated, non-empty, + // and NOT the parent id — they are separate correlation values. + Assert.NotNull(ownExecutionId); + var ownId = Assert.IsType(ownExecutionId); + Assert.NotEqual(Guid.Empty, ownId); + Assert.NotEqual(parentExecutionId, ownId); + } + + [Fact] + public void NoParentExecutionIdSupplied_NonRoutedRun_ParentStaysNull() + { + // A normal (tag-change / timer) script run is not inbound-API-routed — + // no ParentExecutionId is supplied, so _parentExecutionId stays null + // while the run still gets its own fresh ExecutionId. + var context = CreateContext( + externalSystemClient: null, + databaseGateway: null, + auditWriter: null); + + var storedParent = ReadPrivateField(context, "_parentExecutionId"); + var ownExecutionId = ReadPrivateField(context, "_executionId"); + + Assert.Null(storedParent); + var ownId = Assert.IsType(ownExecutionId); + Assert.NotEqual(Guid.Empty, ownId); + } } From 150ba5e63f9fc1075a470093e8b42eae93bb597f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 17:45:55 -0400 Subject: [PATCH 08/19] feat(auditlog): site script-side emitters stamp ParentExecutionId --- .../Scripts/AuditingDbCommand.cs | 19 ++- .../Scripts/AuditingDbConnection.cs | 21 +++- .../Scripts/ScriptRuntimeContext.cs | 23 +++- .../DatabaseCachedWriteEmissionTests.cs | 34 ++++- .../Scripts/DatabaseSyncEmissionTests.cs | 59 ++++++++- .../ExecutionCorrelationContextTests.cs | 119 +++++++++++------- .../ExternalSystemCachedCallEmissionTests.cs | 40 +++++- .../ExternalSystemCallAuditEmissionTests.cs | 49 +++++++- .../Scripts/NotifySendAuditEmissionTests.cs | 39 +++++- 9 files changed, 343 insertions(+), 60 deletions(-) diff --git a/src/ScadaLink.SiteRuntime/Scripts/AuditingDbCommand.cs b/src/ScadaLink.SiteRuntime/Scripts/AuditingDbCommand.cs index e5427ed..52b0e44 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/AuditingDbCommand.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/AuditingDbCommand.cs @@ -38,12 +38,22 @@ internal sealed class AuditingDbCommand : DbCommand private readonly string _instanceName; private readonly string? _sourceScript; private readonly Guid _executionId; + + /// + /// Audit Log #23 (ParentExecutionId): the spawning execution's id when this + /// run was inbound-API-routed; null for non-routed runs. Threaded + /// alongside and stamped onto the DbWrite + /// audit row. + /// + private readonly Guid? _parentExecutionId; + private readonly ILogger _logger; private DbConnection? _wrappingConnection; // Parameter ordering: executionId sits immediately after the ILogger, // consistent with the other three audit-threaded ctors (ExternalSystemHelper, - // DatabaseHelper, AuditingDbConnection). + // DatabaseHelper, AuditingDbConnection). parentExecutionId is a trailing + // optional param so existing positional callers stay source-compatible. public AuditingDbCommand( DbCommand inner, IAuditWriter auditWriter, @@ -52,7 +62,8 @@ internal sealed class AuditingDbCommand : DbCommand string instanceName, string? sourceScript, ILogger logger, - Guid executionId) + Guid executionId, + Guid? parentExecutionId = null) { _inner = inner ?? throw new ArgumentNullException(nameof(inner)); _auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter)); @@ -62,6 +73,7 @@ internal sealed class AuditingDbCommand : DbCommand _sourceScript = sourceScript; _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _executionId = executionId; + _parentExecutionId = parentExecutionId; } // -- Forwarded surface ------------------------------------------------ @@ -438,6 +450,9 @@ internal sealed class AuditingDbCommand : DbCommand // trust-boundary rows from the same script run. CorrelationId = null, ExecutionId = _executionId, + // Audit Log #23 (ParentExecutionId): the spawning execution's id; + // null for non-routed runs. + ParentExecutionId = _parentExecutionId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceInstanceId = _instanceName, SourceScript = _sourceScript, diff --git a/src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs b/src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs index c5a52e1..9ac91c9 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs @@ -37,11 +37,21 @@ internal sealed class AuditingDbConnection : DbConnection private readonly string _instanceName; private readonly string? _sourceScript; private readonly Guid _executionId; + + /// + /// Audit Log #23 (ParentExecutionId): the spawning execution's id when this + /// run was inbound-API-routed; null for non-routed runs. Threaded + /// alongside into the + /// so its DbWrite row stamps it. + /// + private readonly Guid? _parentExecutionId; + private readonly ILogger _logger; // Parameter ordering: executionId sits immediately after the ILogger, // consistent with the other three audit-threaded ctors (ExternalSystemHelper, - // DatabaseHelper, AuditingDbCommand). + // DatabaseHelper, AuditingDbCommand). parentExecutionId is a trailing + // optional param so existing positional callers stay source-compatible. public AuditingDbConnection( DbConnection inner, IAuditWriter auditWriter, @@ -50,7 +60,8 @@ internal sealed class AuditingDbConnection : DbConnection string instanceName, string? sourceScript, ILogger logger, - Guid executionId) + Guid executionId, + Guid? parentExecutionId = null) { _inner = inner ?? throw new ArgumentNullException(nameof(inner)); _auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter)); @@ -60,6 +71,7 @@ internal sealed class AuditingDbConnection : DbConnection _sourceScript = sourceScript; _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _executionId = executionId; + _parentExecutionId = parentExecutionId; } // ConnectionString is settable on DbConnection — forward both halves. @@ -99,7 +111,10 @@ internal sealed class AuditingDbConnection : DbConnection _instanceName, _sourceScript, _logger, - _executionId); + _executionId, + // Audit Log #23 (ParentExecutionId): the spawning execution's id, + // threaded alongside _executionId. Null for non-routed runs. + _parentExecutionId); } protected override void Dispose(bool disposing) diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs index a112928..57e6c11 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs @@ -626,6 +626,9 @@ public class ScriptRuntimeContext // per-execution id shared across this script run. CorrelationId = trackedId.Value, ExecutionId = _executionId, + // Audit Log #23 (ParentExecutionId): the spawning + // execution's id; null for non-routed runs. + ParentExecutionId = _parentExecutionId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceInstanceId = _instanceName, SourceScript = _sourceScript, @@ -739,6 +742,9 @@ public class ScriptRuntimeContext // ExecutionId = per-execution id for this script run. CorrelationId = trackedId.Value, ExecutionId = _executionId, + // Audit Log #23 (ParentExecutionId): the spawning + // execution's id; null for non-routed runs. + ParentExecutionId = _parentExecutionId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceInstanceId = _instanceName, SourceScript = _sourceScript, @@ -803,6 +809,9 @@ public class ScriptRuntimeContext // ExecutionId = per-execution id for this script run. CorrelationId = trackedId.Value, ExecutionId = _executionId, + // Audit Log #23 (ParentExecutionId): the spawning + // execution's id; null for non-routed runs. + ParentExecutionId = _parentExecutionId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceInstanceId = _instanceName, SourceScript = _sourceScript, @@ -980,6 +989,9 @@ public class ScriptRuntimeContext // one script run can be correlated together. CorrelationId = null, ExecutionId = _executionId, + // Audit Log #23 (ParentExecutionId): the spawning execution's + // id; null for non-routed runs. + ParentExecutionId = _parentExecutionId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceInstanceId = _instanceName, SourceScript = _sourceScript, @@ -1127,7 +1139,10 @@ public class ScriptRuntimeContext instanceName: _instanceName, sourceScript: _sourceScript, logger: _logger, - executionId: _executionId); + executionId: _executionId, + // Audit Log #23 (ParentExecutionId): the spawning execution's + // id, threaded alongside _executionId. Null for non-routed runs. + parentExecutionId: _parentExecutionId); } /// @@ -1203,6 +1218,9 @@ public class ScriptRuntimeContext // (TrackedOperationId); ExecutionId = per-execution id. CorrelationId = trackedId.Value, ExecutionId = _executionId, + // Audit Log #23 (ParentExecutionId): the spawning + // execution's id; null for non-routed runs. + ParentExecutionId = _parentExecutionId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceInstanceId = _instanceName, SourceScript = _sourceScript, @@ -1571,6 +1589,9 @@ public class ScriptRuntimeContext // lifecycle id; ExecutionId carries the per-execution id. CorrelationId = correlationId, ExecutionId = _executionId, + // Audit Log #23 (ParentExecutionId): the spawning + // execution's id; null for non-routed runs. + ParentExecutionId = _parentExecutionId, SourceSiteId = string.IsNullOrEmpty(_siteId) ? null : _siteId, SourceInstanceId = _instanceName, SourceScript = _sourceScript, diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs index 405b38e..e10216c 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs @@ -47,7 +47,8 @@ public class DatabaseCachedWriteEmissionTests private static ScriptRuntimeContext.DatabaseHelper CreateHelper( IDatabaseGateway gateway, - ICachedCallTelemetryForwarder? forwarder) + ICachedCallTelemetryForwarder? forwarder, + Guid? parentExecutionId = null) { return new ScriptRuntimeContext.DatabaseHelper( gateway, @@ -59,7 +60,8 @@ public class DatabaseCachedWriteEmissionTests TestExecutionId, siteId: SiteId, sourceScript: SourceScript, - cachedForwarder: forwarder); + cachedForwarder: forwarder, + parentExecutionId: parentExecutionId); } [Fact] @@ -91,6 +93,8 @@ public class DatabaseCachedWriteEmissionTests // ExecutionId is the per-execution id from the runtime context. Assert.Equal(trackedId.Value, packet.Audit.CorrelationId); Assert.Equal(TestExecutionId, packet.Audit.ExecutionId); + // Audit Log #23 (ParentExecutionId): null for a non-routed run. + Assert.Null(packet.Audit.ParentExecutionId); Assert.Equal(trackedId, packet.Operational.TrackedOperationId); Assert.Equal("DbOutbound", packet.Operational.Channel); @@ -126,6 +130,32 @@ public class DatabaseCachedWriteEmissionTests Assert.Equal(SiteId, packet.Operational.SourceSite); } + [Fact] + public async Task CachedWrite_RoutedRun_StampsParentExecutionId_OnSubmitTelemetry() + { + // Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed run + // carries the spawning execution's id; the CachedSubmit telemetry row + // must stamp it in ParentExecutionId. + var parentExecutionId = Guid.NewGuid(); + var gateway = new Mock(); + gateway + .Setup(g => g.CachedWriteAsync( + It.IsAny(), It.IsAny(), + It.IsAny?>(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + var forwarder = new CapturingForwarder(); + + var helper = CreateHelper(gateway.Object, forwarder, parentExecutionId); + await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)"); + + var packet = Assert.Single(forwarder.Telemetry); + Assert.Equal(parentExecutionId, packet.Audit.ParentExecutionId); + } + [Fact] public async Task CachedWrite_ReturnsTrackedOperationId_ThreadsIdToGateway() { diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs index 021cae5..f610e80 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseSyncEmissionTests.cs @@ -63,7 +63,8 @@ public class DatabaseSyncEmissionTests private static ScriptRuntimeContext.DatabaseHelper CreateHelper( IDatabaseGateway gateway, IAuditWriter? auditWriter, - Guid executionId) + Guid executionId, + Guid? parentExecutionId = null) { return new ScriptRuntimeContext.DatabaseHelper( gateway, @@ -73,7 +74,8 @@ public class DatabaseSyncEmissionTests auditWriter: auditWriter, siteId: SiteId, sourceScript: SourceScript, - cachedForwarder: null); + cachedForwarder: null, + parentExecutionId: parentExecutionId); } /// @@ -287,9 +289,62 @@ public class DatabaseSyncEmissionTests // a sync one-shot call has no operation lifecycle. Assert.Equal(TestExecutionId, evt.ExecutionId); Assert.Null(evt.CorrelationId); + // Audit Log #23 (ParentExecutionId): null for a non-routed run — the + // default CreateHelper supplies no parentExecutionId. + Assert.Null(evt.ParentExecutionId); Assert.NotEqual(Guid.Empty, evt.EventId); } + [Fact] + public async Task SyncDbWrite_RoutedRun_StampsParentExecutionId_FromContext() + { + // Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed run + // carries the spawning execution's id; the sync DbWrite row must stamp + // it in ParentExecutionId alongside its own fresh ExecutionId. + using var keepAlive = new SqliteConnection("Data Source=kp;Mode=Memory;Cache=Shared"); + var inner = NewInMemoryDb(out var _); + var gateway = new Mock(); + gateway + .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny())) + .ReturnsAsync(inner); + var writer = new CapturingAuditWriter(); + var executionId = Guid.NewGuid(); + var parentExecutionId = Guid.NewGuid(); + + var helper = CreateHelper(gateway.Object, writer, executionId, parentExecutionId); + await using var conn = await helper.Connection(ConnectionName); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "INSERT INTO t (id, name) VALUES (9, 'theta')"; + await cmd.ExecuteNonQueryAsync(); + + var evt = Assert.Single(writer.Events); + Assert.Equal(parentExecutionId, evt.ParentExecutionId); + Assert.Equal(executionId, evt.ExecutionId); + } + + [Fact] + public async Task SyncDbWrite_NonRoutedRun_ParentExecutionIdIsNull() + { + // A normal (tag/timer) run is not routed — no parent id supplied, so + // the emitted DbWrite row's ParentExecutionId stays null. + using var keepAlive = new SqliteConnection("Data Source=kn;Mode=Memory;Cache=Shared"); + var inner = NewInMemoryDb(out var _); + var gateway = new Mock(); + gateway + .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny())) + .ReturnsAsync(inner); + var writer = new CapturingAuditWriter(); + + var helper = CreateHelper(gateway.Object, writer); + await using var conn = await helper.Connection(ConnectionName); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "INSERT INTO t (id, name) VALUES (10, 'iota')"; + await cmd.ExecuteNonQueryAsync(); + + var evt = Assert.Single(writer.Events); + Assert.Null(evt.ParentExecutionId); + } + [Fact] public async Task SyncDbWrite_StampsExecutionId_AndNullCorrelationId() { diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs index 280830d..ab7412f 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExecutionCorrelationContextTests.cs @@ -92,22 +92,6 @@ public class ExecutionCorrelationContextTests parentExecutionId: parentExecutionId); } - /// - /// Reads a private / field off a - /// . The ParentExecutionId plumbing (Audit - /// Log #23, Task 4) only stores the value on the context — no emitter stamps - /// it onto an audit row yet (that is Task 5) — so the field is inspected - /// directly rather than through an emitted row. - /// - private static object? ReadPrivateField(ScriptRuntimeContext context, string fieldName) - { - var field = typeof(ScriptRuntimeContext).GetField( - fieldName, - System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); - Assert.NotNull(field); - return field!.GetValue(context); - } - /// /// Spin up a fresh in-memory SQLite database with a tiny single-table /// schema. The keep-alive root must outlive any auditing wrapper the test @@ -203,52 +187,99 @@ public class ExecutionCorrelationContextTests } [Fact] - public void ParentExecutionIdSupplied_StoredVerbatim_AndOwnExecutionIdIsFreshAndDistinct() + public async Task ParentExecutionIdSupplied_StampedOnEmittedRow_AndDistinctFromOwnExecutionId() { - // Audit Log #23 (ParentExecutionId, Task 4): an inbound-API-routed call + // Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed call // supplies the spawning execution's ExecutionId as the routed script's - // ParentExecutionId. The context must store that value verbatim AND - // still mint its OWN fresh ExecutionId — the routed script is a new - // execution, it does not inherit the parent's id. + // ParentExecutionId. Every audit row the routed script emits must carry + // that value in AuditEvent.ParentExecutionId — and still carry its OWN + // fresh ExecutionId, distinct from the parent (the routed script is a + // new execution, it does not inherit the parent's id). var parentExecutionId = Guid.NewGuid(); + var client = new Mock(); + client + .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ExternalCallResult(true, "{}", null)); + var writer = new CapturingAuditWriter(); + var context = CreateContext( - externalSystemClient: null, + client.Object, databaseGateway: null, - auditWriter: null, + writer, // executionId omitted — the ctor's `?? Guid.NewGuid()` fallback runs. parentExecutionId: parentExecutionId); + await context.ExternalSystem.Call("ERP", "GetOrder"); - var storedParent = ReadPrivateField(context, "_parentExecutionId"); - var ownExecutionId = ReadPrivateField(context, "_executionId"); - - // The parent id is carried through untouched. - Assert.Equal(parentExecutionId, storedParent); - + var evt = Assert.Single(writer.Events); + // The parent id is stamped on the emitted row untouched. + Assert.Equal(parentExecutionId, evt.ParentExecutionId); // The routed script's own ExecutionId is freshly generated, non-empty, // and NOT the parent id — they are separate correlation values. - Assert.NotNull(ownExecutionId); - var ownId = Assert.IsType(ownExecutionId); - Assert.NotEqual(Guid.Empty, ownId); - Assert.NotEqual(parentExecutionId, ownId); + Assert.NotNull(evt.ExecutionId); + Assert.NotEqual(Guid.Empty, evt.ExecutionId!.Value); + Assert.NotEqual(parentExecutionId, evt.ExecutionId!.Value); } [Fact] - public void NoParentExecutionIdSupplied_NonRoutedRun_ParentStaysNull() + public async Task NoParentExecutionIdSupplied_NonRoutedRun_ParentStaysNullOnEmittedRow() { // A normal (tag-change / timer) script run is not inbound-API-routed — - // no ParentExecutionId is supplied, so _parentExecutionId stays null - // while the run still gets its own fresh ExecutionId. + // no ParentExecutionId is supplied, so every emitted audit row carries + // a null ParentExecutionId while the run still gets its own fresh + // ExecutionId. + var client = new Mock(); + client + .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ExternalCallResult(true, "{}", null)); + var writer = new CapturingAuditWriter(); + + var context = CreateContext(client.Object, databaseGateway: null, writer); + await context.ExternalSystem.Call("ERP", "GetOrder"); + + var evt = Assert.Single(writer.Events); + Assert.Null(evt.ParentExecutionId); + Assert.NotNull(evt.ExecutionId); + Assert.NotEqual(Guid.Empty, evt.ExecutionId!.Value); + } + + [Fact] + public async Task ParentExecutionIdSupplied_StampedOnApiAndDbRows_FromSameContext() + { + // The execution-wide contract extends to ParentExecutionId: an + // ExternalSystem.Call and a sync Database write performed through ONE + // routed context both carry the identical ParentExecutionId. + var parentExecutionId = Guid.NewGuid(); + using var keepAlive = new SqliteConnection("Data Source=ecc-parent;Mode=Memory;Cache=Shared"); + var innerDb = NewInMemoryDb(out var _); + + var client = new Mock(); + client + .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ExternalCallResult(true, "{}", null)); + + var gateway = new Mock(); + gateway + .Setup(g => g.GetConnectionAsync(ConnectionName, It.IsAny())) + .ReturnsAsync(innerDb); + + var writer = new CapturingAuditWriter(); var context = CreateContext( - externalSystemClient: null, - databaseGateway: null, - auditWriter: null); + client.Object, gateway.Object, writer, parentExecutionId: parentExecutionId); - var storedParent = ReadPrivateField(context, "_parentExecutionId"); - var ownExecutionId = ReadPrivateField(context, "_executionId"); + await context.ExternalSystem.Call("ERP", "GetOrder"); - Assert.Null(storedParent); - var ownId = Assert.IsType(ownExecutionId); - Assert.NotEqual(Guid.Empty, ownId); + 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')"; + await cmd.ExecuteNonQueryAsync(); + } + + Assert.Equal(2, writer.Events.Count); + var apiRow = Assert.Single(writer.Events, e => e.Channel == AuditChannel.ApiOutbound); + var dbRow = Assert.Single(writer.Events, e => e.Channel == AuditChannel.DbOutbound); + Assert.Equal(parentExecutionId, apiRow.ParentExecutionId); + Assert.Equal(parentExecutionId, dbRow.ParentExecutionId); } } diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs index ce392cb..4bf31c3 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs @@ -49,7 +49,8 @@ public class ExternalSystemCachedCallEmissionTests private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper( IExternalSystemClient client, - ICachedCallTelemetryForwarder? forwarder) + ICachedCallTelemetryForwarder? forwarder, + Guid? parentExecutionId = null) { return new ScriptRuntimeContext.ExternalSystemHelper( client, @@ -62,7 +63,8 @@ public class ExternalSystemCachedCallEmissionTests auditWriter: null, siteId: SiteId, sourceScript: SourceScript, - cachedForwarder: forwarder); + cachedForwarder: forwarder, + parentExecutionId: parentExecutionId); } [Fact] @@ -386,6 +388,40 @@ public class ExternalSystemCachedCallEmissionTests Assert.Equal("Delivered", resolve.Operational.Status); // Terminal row carries TerminalAtUtc. Assert.NotNull(resolve.Operational.TerminalAtUtc); + + // Audit Log #23 (ParentExecutionId): null on every script-side cached + // row for a non-routed run. + Assert.Null(submit.Audit.ParentExecutionId); + Assert.Null(attempted.Audit.ParentExecutionId); + Assert.Null(resolve.Audit.ParentExecutionId); + } + + [Fact] + public async Task CachedCall_RoutedRun_StampsParentExecutionId_OnAllScriptSideRows() + { + // Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed run + // carries the spawning execution's id; every script-side cached row + // (CachedSubmit, ApiCallCached, CachedResolve) must stamp it in + // ParentExecutionId. + var parentExecutionId = Guid.NewGuid(); + var client = new Mock(); + client + .Setup(c => c.CachedCallAsync( + "ERP", "GetOrder", + It.IsAny?>(), + InstanceName, + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false)); + var forwarder = new CapturingForwarder(); + + var helper = CreateHelper(client.Object, forwarder, parentExecutionId); + await helper.CachedCall("ERP", "GetOrder"); + + Assert.Equal(3, forwarder.Telemetry.Count); + Assert.All(forwarder.Telemetry, t => + Assert.Equal(parentExecutionId, t.Audit.ParentExecutionId)); } /// diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs index 6632783..b9e7948 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs @@ -60,7 +60,8 @@ public class ExternalSystemCallAuditEmissionTests private static ScriptRuntimeContext.ExternalSystemHelper CreateHelper( IExternalSystemClient client, IAuditWriter? auditWriter, - Guid executionId) + Guid executionId, + Guid? parentExecutionId = null) { return new ScriptRuntimeContext.ExternalSystemHelper( client, @@ -69,7 +70,9 @@ public class ExternalSystemCallAuditEmissionTests executionId, auditWriter, SiteId, - SourceScript); + SourceScript, + cachedForwarder: null, + parentExecutionId: parentExecutionId); } [Fact] @@ -230,6 +233,48 @@ public class ExternalSystemCallAuditEmissionTests // a sync one-shot call has no operation lifecycle. Assert.Equal(TestExecutionId, evt.ExecutionId); Assert.Null(evt.CorrelationId); + // Audit Log #23 (ParentExecutionId): null for a non-routed run — the + // default CreateHelper supplies no parentExecutionId. + Assert.Null(evt.ParentExecutionId); + } + + [Fact] + public async Task Call_RoutedRun_StampsParentExecutionId_FromContext() + { + // Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed run + // carries the spawning execution's id; the sync ApiCall row must stamp + // it in ParentExecutionId alongside its own fresh ExecutionId. + var client = new Mock(); + client + .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ExternalCallResult(true, "{}", null)); + var writer = new CapturingAuditWriter(); + var parentExecutionId = Guid.NewGuid(); + + var helper = CreateHelper(client.Object, writer, TestExecutionId, parentExecutionId); + await helper.Call("ERP", "GetOrder"); + + var evt = Assert.Single(writer.Events); + Assert.Equal(parentExecutionId, evt.ParentExecutionId); + Assert.Equal(TestExecutionId, evt.ExecutionId); + } + + [Fact] + public async Task Call_NonRoutedRun_ParentExecutionIdIsNull() + { + // A normal (tag/timer) run is not routed — no parent id supplied, so + // the emitted ApiCall row's ParentExecutionId stays null. + var client = new Mock(); + client + .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ExternalCallResult(true, "{}", null)); + var writer = new CapturingAuditWriter(); + + var helper = CreateHelper(client.Object, writer); + await helper.Call("ERP", "GetOrder"); + + var evt = Assert.Single(writer.Events); + Assert.Null(evt.ParentExecutionId); } [Fact] diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifySendAuditEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifySendAuditEmissionTests.cs index 402821b..89fdbea 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifySendAuditEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifySendAuditEmissionTests.cs @@ -95,7 +95,8 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable private ScriptRuntimeContext.NotifyHelper CreateHelper( IAuditWriter? auditWriter, - string? sourceScript = SourceScript) + string? sourceScript = SourceScript, + Guid? parentExecutionId = null) { // siteCommunicationActor is unused by Send — pass a probe so the helper // is fully constructed. @@ -109,7 +110,8 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable TimeSpan.FromSeconds(3), NullLogger.Instance, TestExecutionId, - auditWriter); + auditWriter, + parentExecutionId: parentExecutionId); } [Fact] @@ -229,6 +231,39 @@ public class NotifySendAuditEmissionTests : TestKit, IAsyncLifetime, IDisposable Assert.NotNull(evt.CorrelationId); Assert.Equal(expected, evt.CorrelationId); Assert.Equal(TestExecutionId, evt.ExecutionId); + // Audit Log #23 (ParentExecutionId): null for a non-routed run. + Assert.Null(evt.ParentExecutionId); + } + + [Fact] + public async Task Send_RoutedRun_StampsParentExecutionId_OnNotifySendRow() + { + // Audit Log #23 (ParentExecutionId, Task 5): an inbound-API-routed run + // carries the spawning execution's id; the NotifySend row must stamp + // it in ParentExecutionId alongside its own ExecutionId. + var parentExecutionId = Guid.NewGuid(); + var writer = new CapturingAuditWriter(); + var notify = CreateHelper(writer, parentExecutionId: parentExecutionId); + + await notify.To(ListName).Send(Subject, Body); + + var evt = Assert.Single(writer.Events); + Assert.Equal(parentExecutionId, evt.ParentExecutionId); + Assert.Equal(TestExecutionId, evt.ExecutionId); + } + + [Fact] + public async Task Send_NonRoutedRun_ParentExecutionIdIsNull() + { + // A normal (tag/timer) run is not routed — the NotifySend row's + // ParentExecutionId stays null. + var writer = new CapturingAuditWriter(); + var notify = CreateHelper(writer); + + await notify.To(ListName).Send(Subject, Body); + + var evt = Assert.Single(writer.Events); + Assert.Null(evt.ParentExecutionId); } [Fact] From c00603e2a4da8bc9e99c1213064b6e3f74c9cc51 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 17:58:11 -0400 Subject: [PATCH 09/19] feat(auditlog): thread ParentExecutionId through S&F for retry-loop cached rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store-and-forward retry loop emits the per-attempt and terminal cached audit rows (ApiCallCached/DbWriteCached Attempted, CachedResolve) via CachedCallLifecycleBridge from a CachedCallAttemptContext, not from the script context. The ExecutionId rollout (Task 4) already threaded ExecutionId and SourceScript through this path; ParentExecutionId — the spawning inbound-API request's ExecutionId — was not, so those retry-loop rows had ParentExecutionId = null even for an inbound-API-routed run. Thread it additively as a sibling at every carry point ExecutionId passes through: - StoreAndForwardMessage gains ParentExecutionId (Guid?). - StoreAndForwardStorage adds a nullable parent_execution_id column via the same idempotent PRAGMA-probed ALTER TABLE migration; rows persisted by an older build read back null (back-compat). The defensive Guid.TryParse read helper (ParseExecutionId) is renamed ParseGuidColumn and reused for both columns so a corrupt value cannot abort the retry sweep. - StoreAndForwardService.EnqueueAsync gains an optional parentExecutionId param, stamped onto the buffered message and surfaced on the CachedCallAttemptContext built in the retry loop. - CachedCallAttemptContext gains ParentExecutionId. - CachedCallLifecycleBridge.BuildPacket sets AuditEvent.ParentExecutionId from the context, beside the existing ExecutionId. - IExternalSystemClient.CachedCallAsync / IDatabaseGateway.CachedWriteAsync gain an optional parentExecutionId param; ScriptRuntimeContext's CachedCall / CachedWrite helpers pass _parentExecutionId. All threading is additive — ParentExecutionId is Guid? everywhere, null for non-routed runs, and old buffered S&F rows still deserialize with the new field null. --- .../Telemetry/CachedCallLifecycleBridge.cs | 6 + .../Services/ICachedCallLifecycleObserver.cs | 13 +- .../Interfaces/Services/IDatabaseGateway.cs | 11 +- .../Services/IExternalSystemClient.cs | 11 +- .../DatabaseGateway.cs | 10 +- .../ExternalSystemClient.cs | 10 +- .../Scripts/ScriptRuntimeContext.cs | 14 +- .../StoreAndForwardMessage.cs | 15 ++ .../StoreAndForwardService.cs | 22 ++- .../StoreAndForwardStorage.cs | 42 ++++-- .../CachedCallLifecycleBridgeTests.cs | 72 +++++++++- .../DatabaseCachedWriteEmissionTests.cs | 90 ++++++++++-- .../ExternalSystemCachedCallEmissionTests.cs | 104 +++++++++++--- .../CachedCallAttemptEmissionTests.cs | 77 ++++++++++ .../StoreAndForwardStorageTests.cs | 135 ++++++++++++++++++ 15 files changed, 581 insertions(+), 51 deletions(-) diff --git a/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs b/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs index 258bbaa..121370f 100644 --- a/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs +++ b/src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs @@ -137,6 +137,12 @@ public sealed class CachedCallLifecycleBridge : ICachedCallLifecycleObserver // execution's per-run correlation id, threaded through the S&F // buffer; null on rows buffered before Task 4 (back-compat). ExecutionId = context.ExecutionId, + // Audit Log #23 (ParentExecutionId Task 6): the spawning + // inbound-API request's ExecutionId, threaded through the S&F + // buffer alongside ExecutionId so the retry-loop cached rows + // correlate back to the cross-execution chain. Null for a + // non-routed run and on rows buffered before Task 6. + ParentExecutionId = context.ParentExecutionId, SourceSiteId = string.IsNullOrEmpty(context.SourceSite) ? null : context.SourceSite, SourceInstanceId = context.SourceInstanceId, // Audit Log #23 (ExecutionId Task 4): SourceScript is now diff --git a/src/ScadaLink.Commons/Interfaces/Services/ICachedCallLifecycleObserver.cs b/src/ScadaLink.Commons/Interfaces/Services/ICachedCallLifecycleObserver.cs index 6188f0c..cbc365c 100644 --- a/src/ScadaLink.Commons/Interfaces/Services/ICachedCallLifecycleObserver.cs +++ b/src/ScadaLink.Commons/Interfaces/Services/ICachedCallLifecycleObserver.cs @@ -71,6 +71,16 @@ public interface ICachedCallLifecycleObserver /// rows carry the same SourceScript provenance the script-side cached /// rows already do. null when not known. /// +/// +/// Audit Log #23 (ParentExecutionId Task 6): the ExecutionId of the +/// inbound-API request that spawned the originating script execution, +/// threaded through the store-and-forward buffer alongside +/// . The audit bridge stamps it onto the +/// retry-loop ApiCallCached/DbWriteCached Attempted and +/// CachedResolve rows so they correlate back to the spawning run. +/// null for a non-routed run and for rows buffered before Task 6 +/// (back-compat). +/// public sealed record CachedCallAttemptContext( TrackedOperationId TrackedOperationId, string Channel, @@ -85,7 +95,8 @@ public sealed record CachedCallAttemptContext( int? DurationMs, string? SourceInstanceId, Guid? ExecutionId = null, - string? SourceScript = null); + string? SourceScript = null, + Guid? ParentExecutionId = null); /// /// Coarse outcome of one cached-call delivery attempt, observed from inside diff --git a/src/ScadaLink.Commons/Interfaces/Services/IDatabaseGateway.cs b/src/ScadaLink.Commons/Interfaces/Services/IDatabaseGateway.cs index 0271bec..c7a89fd 100644 --- a/src/ScadaLink.Commons/Interfaces/Services/IDatabaseGateway.cs +++ b/src/ScadaLink.Commons/Interfaces/Services/IDatabaseGateway.cs @@ -40,6 +40,14 @@ public interface IDatabaseGateway /// threaded onto the buffered S&F message alongside /// . null when not known. /// + /// + /// Audit Log #23 (ParentExecutionId Task 6): the ExecutionId of the + /// inbound-API request that spawned the originating script execution. + /// When the write is buffered on a transient failure this is threaded onto + /// the S&F message alongside so the + /// retry-loop cached-write audit rows carry it. null for a + /// non-routed run. + /// Task CachedWriteAsync( string connectionName, string sql, @@ -48,5 +56,6 @@ public interface IDatabaseGateway CancellationToken cancellationToken = default, TrackedOperationId? trackedOperationId = null, Guid? executionId = null, - string? sourceScript = null); + string? sourceScript = null, + Guid? parentExecutionId = null); } diff --git a/src/ScadaLink.Commons/Interfaces/Services/IExternalSystemClient.cs b/src/ScadaLink.Commons/Interfaces/Services/IExternalSystemClient.cs index d4f855c..107156f 100644 --- a/src/ScadaLink.Commons/Interfaces/Services/IExternalSystemClient.cs +++ b/src/ScadaLink.Commons/Interfaces/Services/IExternalSystemClient.cs @@ -41,6 +41,14 @@ public interface IExternalSystemClient /// threaded onto the buffered S&F message alongside /// . null when not known. /// + /// + /// Audit Log #23 (ParentExecutionId Task 6): the ExecutionId of the + /// inbound-API request that spawned the originating script execution. + /// When the call is buffered on a transient failure this is threaded onto + /// the S&F message alongside so the + /// retry-loop cached-call audit rows carry it. null for a non-routed + /// run. + /// Task CachedCallAsync( string systemName, string methodName, @@ -49,7 +57,8 @@ public interface IExternalSystemClient CancellationToken cancellationToken = default, TrackedOperationId? trackedOperationId = null, Guid? executionId = null, - string? sourceScript = null); + string? sourceScript = null, + Guid? parentExecutionId = null); } /// diff --git a/src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs b/src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs index efa29c4..3ac3794 100644 --- a/src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs +++ b/src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs @@ -86,7 +86,8 @@ public class DatabaseGateway : IDatabaseGateway CancellationToken cancellationToken = default, TrackedOperationId? trackedOperationId = null, Guid? executionId = null, - string? sourceScript = null) + string? sourceScript = null, + Guid? parentExecutionId = null) { var definition = await ResolveConnectionAsync(connectionName, cancellationToken); if (definition == null) @@ -132,7 +133,12 @@ public class DatabaseGateway : IDatabaseGateway // the retry-loop cached-write audit rows carry the same provenance // the script-side cached rows do. executionId: executionId, - sourceScript: sourceScript); + sourceScript: sourceScript, + // Audit Log #23 (ParentExecutionId Task 6): thread the spawning + // inbound-API request's ExecutionId onto the buffered row so the + // retry-loop cached-write audit rows correlate back to the + // cross-execution chain. Null for a non-routed run. + parentExecutionId: parentExecutionId); } /// diff --git a/src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs b/src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs index dfe042b..6db1093 100644 --- a/src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs +++ b/src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs @@ -88,7 +88,8 @@ public class ExternalSystemClient : IExternalSystemClient CancellationToken cancellationToken = default, TrackedOperationId? trackedOperationId = null, Guid? executionId = null, - string? sourceScript = null) + string? sourceScript = null, + Guid? parentExecutionId = null) { var (system, method) = await ResolveSystemAndMethodAsync(systemName, methodName, cancellationToken); if (system == null || method == null) @@ -152,7 +153,12 @@ public class ExternalSystemClient : IExternalSystemClient // buffered row so the retry-loop cached-call audit rows carry // the same provenance the script-side cached rows do. executionId: executionId, - sourceScript: sourceScript); + sourceScript: sourceScript, + // Audit Log #23 (ParentExecutionId Task 6): thread the spawning + // inbound-API request's ExecutionId onto the buffered row so + // the retry-loop cached-call audit rows correlate back to the + // cross-execution chain. Null for a non-routed run. + parentExecutionId: parentExecutionId); return new ExternalCallResult(true, null, null, WasBuffered: true); } diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs index 57e6c11..f581645 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs @@ -564,7 +564,12 @@ public class ScriptRuntimeContext // execution's ExecutionId + SourceScript so a buffered // cached call's retry-loop audit rows carry them. executionId: _executionId, - sourceScript: _sourceScript).ConfigureAwait(false); + sourceScript: _sourceScript, + // Audit Log #23 (ParentExecutionId Task 6): thread the + // spawning inbound-API request's ExecutionId so a buffered + // cached call's retry-loop audit rows carry it too. Null + // for a non-routed run. + parentExecutionId: _parentExecutionId).ConfigureAwait(false); } catch (Exception ex) { @@ -1178,7 +1183,12 @@ public class ScriptRuntimeContext // execution's ExecutionId + SourceScript so a buffered // cached write's retry-loop audit rows carry them. executionId: _executionId, - sourceScript: _sourceScript) + sourceScript: _sourceScript, + // Audit Log #23 (ParentExecutionId Task 6): thread the + // spawning inbound-API request's ExecutionId so a buffered + // cached write's retry-loop audit rows carry it too. Null + // for a non-routed run. + parentExecutionId: _parentExecutionId) .ConfigureAwait(false); } catch (Exception ex) diff --git a/src/ScadaLink.StoreAndForward/StoreAndForwardMessage.cs b/src/ScadaLink.StoreAndForward/StoreAndForwardMessage.cs index 3e799ac..ba521af 100644 --- a/src/ScadaLink.StoreAndForward/StoreAndForwardMessage.cs +++ b/src/ScadaLink.StoreAndForward/StoreAndForwardMessage.cs @@ -76,4 +76,19 @@ public class StoreAndForwardMessage /// known (non-cached categories, pre-migration rows). /// public string? SourceScript { get; set; } + + /// + /// Audit Log #23 (ParentExecutionId Task 6): the ExecutionId of the + /// inbound-API request that spawned the originating script execution, + /// threaded alongside from 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 + /// (ApiCallCached/DbWriteCached Attempted, CachedResolve), + /// keeping them correlated with the cross-execution chain. null for a + /// non-routed run, 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). + /// + public Guid? ParentExecutionId { get; set; } } diff --git a/src/ScadaLink.StoreAndForward/StoreAndForwardService.cs b/src/ScadaLink.StoreAndForward/StoreAndForwardService.cs index fe27528..a8d6245 100644 --- a/src/ScadaLink.StoreAndForward/StoreAndForwardService.cs +++ b/src/ScadaLink.StoreAndForward/StoreAndForwardService.cs @@ -187,6 +187,14 @@ public class StoreAndForwardService /// so the retry-loop audit rows carry the same provenance the script-side /// cached rows do. null when not known. /// + /// + /// Audit Log #23 (ParentExecutionId Task 6): the ExecutionId of the + /// inbound-API request that spawned the originating script execution. + /// Threaded onto the buffered row alongside + /// so the retry-loop cached-call audit rows carry it. null for a + /// non-routed run and for callers (notifications, pre-Task-6 callers) that + /// do not supply one. + /// public async Task EnqueueAsync( StoreAndForwardCategory category, string target, @@ -197,7 +205,8 @@ public class StoreAndForwardService bool attemptImmediateDelivery = true, string? messageId = null, Guid? executionId = null, - string? sourceScript = null) + string? sourceScript = null, + Guid? parentExecutionId = null) { var message = new StoreAndForwardMessage { @@ -212,7 +221,8 @@ public class StoreAndForwardService Status = StoreAndForwardMessageStatus.Pending, OriginInstanceName = originInstanceName, ExecutionId = executionId, - SourceScript = sourceScript + SourceScript = sourceScript, + ParentExecutionId = parentExecutionId }; // Attempt immediate delivery — unless the caller has already made a @@ -515,7 +525,13 @@ public class StoreAndForwardService // stamp the retry-loop cached audit rows. Null on rows buffered // before Task 4 (back-compat). ExecutionId: message.ExecutionId, - SourceScript: message.SourceScript); + SourceScript: message.SourceScript, + // Audit Log #23 (ParentExecutionId Task 6): the buffered + // message also carries the spawning inbound-API request's + // ExecutionId; surface it so the bridge stamps it onto the + // retry-loop cached rows. Null for a non-routed run and on + // rows buffered before Task 6 (back-compat). + ParentExecutionId: message.ParentExecutionId); } catch (Exception buildEx) { diff --git a/src/ScadaLink.StoreAndForward/StoreAndForwardStorage.cs b/src/ScadaLink.StoreAndForward/StoreAndForwardStorage.cs index f7564fa..6328f96 100644 --- a/src/ScadaLink.StoreAndForward/StoreAndForwardStorage.cs +++ b/src/ScadaLink.StoreAndForward/StoreAndForwardStorage.cs @@ -76,6 +76,12 @@ public class StoreAndForwardStorage await AddColumnIfMissingAsync(connection, "execution_id", "TEXT"); await AddColumnIfMissingAsync(connection, "source_script", "TEXT"); + // Audit Log #23 (ParentExecutionId Task 6): additively add the + // parent_execution_id column the same way — a sibling to execution_id. + // Nullable with no default, so any row buffered before this migration + // reads back ParentExecutionId = null (back-compat). + await AddColumnIfMissingAsync(connection, "parent_execution_id", "TEXT"); + _logger.LogInformation("Store-and-forward SQLite storage initialized"); } @@ -142,10 +148,10 @@ public class StoreAndForwardStorage 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, execution_id, source_script) + origin_instance, execution_id, source_script, parent_execution_id) VALUES (@id, @category, @target, @payload, @retryCount, @maxRetries, @retryIntervalMs, @createdAt, @lastAttempt, @status, @lastError, - @origin, @executionId, @sourceScript)"; + @origin, @executionId, @sourceScript, @parentExecutionId)"; cmd.Parameters.AddWithValue("@id", message.Id); cmd.Parameters.AddWithValue("@category", (int)message.Category); @@ -166,6 +172,11 @@ public class StoreAndForwardStorage cmd.Parameters.AddWithValue("@executionId", message.ExecutionId.HasValue ? message.ExecutionId.Value.ToString("D") : DBNull.Value); cmd.Parameters.AddWithValue("@sourceScript", (object?)message.SourceScript ?? DBNull.Value); + // Audit Log #23 (ParentExecutionId Task 6): the parent execution id is + // stored as its canonical string form ("D") so it round-trips cleanly + // through the TEXT column; null when not a routed cached call. + cmd.Parameters.AddWithValue("@parentExecutionId", + message.ParentExecutionId.HasValue ? message.ParentExecutionId.Value.ToString("D") : DBNull.Value); await cmd.ExecuteNonQueryAsync(); } @@ -182,7 +193,7 @@ public class StoreAndForwardStorage 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, - execution_id, source_script + execution_id, source_script, parent_execution_id FROM sf_messages WHERE status = @pending AND (last_attempt_at IS NULL @@ -314,7 +325,7 @@ public class StoreAndForwardStorage 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, - execution_id, source_script + execution_id, source_script, parent_execution_id FROM sf_messages WHERE status = @parked{categoryFilter} ORDER BY created_at ASC @@ -436,7 +447,7 @@ public class StoreAndForwardStorage 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, - execution_id, source_script + execution_id, source_script, parent_execution_id FROM sf_messages WHERE id = @id"; cmd.Parameters.AddWithValue("@id", messageId); @@ -500,28 +511,35 @@ public class StoreAndForwardStorage // 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) + ExecutionId = ParseGuidColumn(reader, 12), + SourceScript = reader.IsDBNull(13) ? null : reader.GetString(13), + // Audit Log #23 (ParentExecutionId Task 6): rows persisted + // before the additive migration have no parent_execution_id + // value; the IsDBNull guard inside ParseGuidColumn keeps those + // reading back as null (back-compat). Guid.TryParse (not Parse) + // guards the retry sweep against a corrupt non-null value. + ParentExecutionId = ParseGuidColumn(reader, 14) }); } return results; } /// - /// Audit Log #23 (ExecutionId Task 4): defensively reads the - /// execution_id column. A null value (legacy pre-migration + /// Audit Log #23 (ExecutionId Task 4 / ParentExecutionId Task 6): + /// defensively reads a nullable GUID column (execution_id or + /// parent_execution_id). A null value (legacy pre-migration /// rows) and a malformed non-null value both yield null — a corrupt /// id must not throw and abort the retry sweep, which reads many rows. /// - private static Guid? ParseExecutionId(System.Data.Common.DbDataReader reader, int ordinal) + private static Guid? ParseGuidColumn(System.Data.Common.DbDataReader reader, int ordinal) { if (reader.IsDBNull(ordinal)) { return null; } - return Guid.TryParse(reader.GetString(ordinal), out var executionId) - ? executionId + return Guid.TryParse(reader.GetString(ordinal), out var value) + ? value : null; } } diff --git a/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallLifecycleBridgeTests.cs b/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallLifecycleBridgeTests.cs index 1438cfe..4185ab1 100644 --- a/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallLifecycleBridgeTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Site/Telemetry/CachedCallLifecycleBridgeTests.cs @@ -33,7 +33,8 @@ public class CachedCallLifecycleBridgeTests string? lastError = null, int? httpStatus = null, Guid? executionId = null, - string? sourceScript = null) => + string? sourceScript = null, + Guid? parentExecutionId = null) => new( TrackedOperationId: _id, Channel: channel, @@ -48,7 +49,8 @@ public class CachedCallLifecycleBridgeTests DurationMs: 42, SourceInstanceId: "Plant.Pump42", ExecutionId: executionId, - SourceScript: sourceScript); + SourceScript: sourceScript, + ParentExecutionId: parentExecutionId); [Fact] public async Task TransientFailure_EmitsOneAttemptedRow_NoResolve() @@ -259,4 +261,70 @@ public class CachedCallLifecycleBridgeTests Assert.Null(captured!.Audit.ExecutionId); Assert.Null(captured.Audit.SourceScript); } + + // ── Audit Log #23 (ParentExecutionId Task 6): ParentExecutionId ── + + [Fact] + public async Task RetryLoopAttemptedRow_CarriesParentExecutionId_FromContext() + { + // Task 6: the ParentExecutionId threaded through the S&F buffer (the + // inbound-API run that spawned the originating script) arrives on the + // CachedCallAttemptContext; the bridge must stamp it onto the + // per-attempt ApiCallCached row beside ExecutionId. + var parentExecutionId = Guid.NewGuid(); + var captured = new List(); + _forwarder.ForwardAsync(Arg.Do(t => captured.Add(t)), Arg.Any()) + .Returns(Task.CompletedTask); + + var sut = CreateSut(); + await sut.OnAttemptCompletedAsync(Ctx( + CachedCallAttemptOutcome.TransientFailure, + parentExecutionId: parentExecutionId)); + + var packet = Assert.Single(captured); + Assert.Equal(AuditKind.ApiCallCached, packet.Audit.Kind); + Assert.Equal(parentExecutionId, packet.Audit.ParentExecutionId); + } + + [Fact] + public async Task RetryLoopCachedResolveRow_CarriesParentExecutionId_FromContext() + { + // The terminal CachedResolve row must also carry the threaded + // ParentExecutionId so the whole retry-loop lifecycle correlates back + // to the spawning inbound-API execution. + var parentExecutionId = Guid.NewGuid(); + var captured = new List(); + _forwarder.ForwardAsync(Arg.Do(t => captured.Add(t)), Arg.Any()) + .Returns(Task.CompletedTask); + + var sut = CreateSut(); + await sut.OnAttemptCompletedAsync(Ctx( + CachedCallAttemptOutcome.Delivered, + channel: "DbOutbound", + parentExecutionId: parentExecutionId)); + + Assert.Equal(2, captured.Count); + var resolve = Assert.Single(captured, p => p.Audit.Kind == AuditKind.CachedResolve); + Assert.Equal(parentExecutionId, resolve.Audit.ParentExecutionId); + + var attempted = Assert.Single(captured, p => p.Audit.Kind == AuditKind.DbWriteCached); + Assert.Equal(parentExecutionId, attempted.Audit.ParentExecutionId); + } + + [Fact] + public async Task RetryLoopRow_NullParentExecutionId_RemainsNull() + { + // Back-compat / non-routed run: the originating script was not spawned + // by an inbound-API request, so ParentExecutionId is null; the bridge + // must leave the audit row's ParentExecutionId null rather than throwing. + CachedCallTelemetry? captured = null; + _forwarder.ForwardAsync(Arg.Do(t => captured = t), Arg.Any()) + .Returns(Task.CompletedTask); + + var sut = CreateSut(); + await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.TransientFailure)); + + Assert.NotNull(captured); + Assert.Null(captured!.Audit.ParentExecutionId); + } } diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs index e10216c..b993083 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/DatabaseCachedWriteEmissionTests.cs @@ -75,7 +75,7 @@ public class DatabaseCachedWriteEmissionTests InstanceName, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var forwarder = new CapturingForwarder(); @@ -116,7 +116,7 @@ public class DatabaseCachedWriteEmissionTests It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var forwarder = new CapturingForwarder(); @@ -145,7 +145,7 @@ public class DatabaseCachedWriteEmissionTests It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var forwarder = new CapturingForwarder(); @@ -167,7 +167,7 @@ public class DatabaseCachedWriteEmissionTests It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var forwarder = new CapturingForwarder(); @@ -181,7 +181,7 @@ public class DatabaseCachedWriteEmissionTests InstanceName, It.IsAny(), trackedId, - It.IsAny(), It.IsAny()), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } @@ -205,7 +205,7 @@ public class DatabaseCachedWriteEmissionTests InstanceName, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var forwarder = new CapturingForwarder(); @@ -221,7 +221,79 @@ public class DatabaseCachedWriteEmissionTests It.IsAny(), It.IsAny(), It.Is(id => id == TestExecutionId), - It.Is(s => s == SourceScript)), + It.Is(s => s == SourceScript), + It.IsAny()), + Times.Once); + } + + /// + /// Audit Log #23 (ParentExecutionId Task 6): the helper → gateway hop for + /// ParentExecutionId. A cached write enqueued from an inbound-API- + /// routed script run must forward the runtime context's + /// ParentExecutionId verbatim into + /// so the buffered retry + /// loop later stamps it onto its audit rows. + /// + [Fact] + public async Task CachedWrite_ThreadsParentExecutionId_IntoGateway() + { + var parentExecutionId = Guid.NewGuid(); + var gateway = new Mock(); + gateway + .Setup(g => g.CachedWriteAsync( + "myDb", "INSERT INTO t VALUES (1)", + It.IsAny?>(), + InstanceName, + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + var forwarder = new CapturingForwarder(); + + var helper = CreateHelper(gateway.Object, forwarder, parentExecutionId); + await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)"); + + gateway.Verify(g => g.CachedWriteAsync( + "myDb", "INSERT INTO t VALUES (1)", + It.IsAny?>(), + InstanceName, + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), + It.Is(id => id == parentExecutionId)), + Times.Once); + } + + /// + /// Audit Log #23 (ParentExecutionId Task 6): a non-routed run threads a + /// null ParentExecutionId into the gateway — the additive default. + /// + [Fact] + public async Task CachedWrite_NonRoutedRun_ThreadsNullParentExecutionId_IntoGateway() + { + var gateway = new Mock(); + gateway + .Setup(g => g.CachedWriteAsync( + "myDb", "INSERT INTO t VALUES (1)", + It.IsAny?>(), + InstanceName, + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + var forwarder = new CapturingForwarder(); + + var helper = CreateHelper(gateway.Object, forwarder); + await helper.CachedWrite("myDb", "INSERT INTO t VALUES (1)"); + + gateway.Verify(g => g.CachedWriteAsync( + "myDb", "INSERT INTO t VALUES (1)", + It.IsAny?>(), + InstanceName, + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), + It.Is(id => id == null)), Times.Once); } @@ -236,7 +308,7 @@ public class DatabaseCachedWriteEmissionTests It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var forwarder = new CapturingForwarder { @@ -253,7 +325,7 @@ public class DatabaseCachedWriteEmissionTests InstanceName, It.IsAny(), trackedId, - It.IsAny(), It.IsAny()), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } } diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs index 4bf31c3..7ed9725 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCachedCallEmissionTests.cs @@ -78,7 +78,7 @@ public class ExternalSystemCachedCallEmissionTests InstanceName, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true)); var forwarder = new CapturingForwarder(); @@ -121,7 +121,7 @@ public class ExternalSystemCachedCallEmissionTests InstanceName, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false)); var forwarder = new CapturingForwarder(); @@ -158,7 +158,7 @@ public class ExternalSystemCachedCallEmissionTests It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true)); var forwarder = new CapturingForwarder(); @@ -178,7 +178,7 @@ public class ExternalSystemCachedCallEmissionTests InstanceName, It.IsAny(), id1, - It.IsAny(), It.IsAny()), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); client.Verify(c => c.CachedCallAsync( "ERP", "GetOrder", @@ -186,7 +186,7 @@ public class ExternalSystemCachedCallEmissionTests InstanceName, It.IsAny(), id2, - It.IsAny(), It.IsAny()), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } @@ -210,7 +210,7 @@ public class ExternalSystemCachedCallEmissionTests InstanceName, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true)); var forwarder = new CapturingForwarder(); @@ -226,7 +226,79 @@ public class ExternalSystemCachedCallEmissionTests It.IsAny(), It.IsAny(), It.Is(id => id == TestExecutionId), - It.Is(s => s == SourceScript)), + It.Is(s => s == SourceScript), + It.IsAny()), + Times.Once); + } + + /// + /// Audit Log #23 (ParentExecutionId Task 6): the helper → gateway hop for + /// ParentExecutionId. A cached call enqueued from an inbound-API- + /// routed script run must forward the runtime context's + /// ParentExecutionId verbatim into + /// so the buffered + /// retry loop later stamps it onto its audit rows. + /// + [Fact] + public async Task CachedCall_ThreadsParentExecutionId_IntoClient() + { + var parentExecutionId = Guid.NewGuid(); + var client = new Mock(); + client + .Setup(c => c.CachedCallAsync( + "ERP", "GetOrder", + It.IsAny?>(), + InstanceName, + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true)); + var forwarder = new CapturingForwarder(); + + var helper = CreateHelper(client.Object, forwarder, parentExecutionId); + await helper.CachedCall("ERP", "GetOrder"); + + client.Verify(c => c.CachedCallAsync( + "ERP", "GetOrder", + It.IsAny?>(), + InstanceName, + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), + It.Is(id => id == parentExecutionId)), + Times.Once); + } + + /// + /// Audit Log #23 (ParentExecutionId Task 6): a non-routed run threads a + /// null ParentExecutionId into the client — the additive default. + /// + [Fact] + public async Task CachedCall_NonRoutedRun_ThreadsNullParentExecutionId_IntoClient() + { + var client = new Mock(); + client + .Setup(c => c.CachedCallAsync( + "ERP", "GetOrder", + It.IsAny?>(), + InstanceName, + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true)); + var forwarder = new CapturingForwarder(); + + var helper = CreateHelper(client.Object, forwarder); + await helper.CachedCall("ERP", "GetOrder"); + + client.Verify(c => c.CachedCallAsync( + "ERP", "GetOrder", + It.IsAny?>(), + InstanceName, + It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), + It.Is(id => id == null)), Times.Once); } @@ -241,7 +313,7 @@ public class ExternalSystemCachedCallEmissionTests It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true)); var forwarder = new CapturingForwarder { @@ -261,7 +333,7 @@ public class ExternalSystemCachedCallEmissionTests InstanceName, It.IsAny(), trackedId, - It.IsAny(), It.IsAny()), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } @@ -276,7 +348,7 @@ public class ExternalSystemCachedCallEmissionTests It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true)); var forwarder = new CapturingForwarder(); @@ -303,7 +375,7 @@ public class ExternalSystemCachedCallEmissionTests It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, null, null, WasBuffered: true)); var helper = CreateHelper(client.Object, forwarder: null); @@ -316,7 +388,7 @@ public class ExternalSystemCachedCallEmissionTests InstanceName, It.IsAny(), trackedId, - It.IsAny(), It.IsAny()), + It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } @@ -346,7 +418,7 @@ public class ExternalSystemCachedCallEmissionTests InstanceName, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) // WasBuffered=false — the immediate HTTP attempt succeeded; S&F // is bypassed entirely. .ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false)); @@ -412,7 +484,7 @@ public class ExternalSystemCachedCallEmissionTests InstanceName, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false)); var forwarder = new CapturingForwarder(); @@ -442,7 +514,7 @@ public class ExternalSystemCachedCallEmissionTests InstanceName, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ExternalCallResult( false, null, "Permanent error: HTTP 422 bad payload", WasBuffered: false)); var forwarder = new CapturingForwarder(); @@ -485,7 +557,7 @@ public class ExternalSystemCachedCallEmissionTests InstanceName, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) // 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)); diff --git a/tests/ScadaLink.StoreAndForward.Tests/CachedCallAttemptEmissionTests.cs b/tests/ScadaLink.StoreAndForward.Tests/CachedCallAttemptEmissionTests.cs index 501bc3b..145ac83 100644 --- a/tests/ScadaLink.StoreAndForward.Tests/CachedCallAttemptEmissionTests.cs +++ b/tests/ScadaLink.StoreAndForward.Tests/CachedCallAttemptEmissionTests.cs @@ -357,6 +357,83 @@ public class CachedCallAttemptEmissionTests : IAsyncLifetime, IDisposable Assert.Equal("Plant.Tank/OnAlarm", notification.SourceScript); } + // ── Audit Log #23 (ParentExecutionId Task 6): ParentExecutionId ── + + [Fact] + public async Task Attempt_CarriesParentExecutionId_FromBufferedMessage() + { + // A cached call enqueued from an inbound-API-routed script run carries + // the spawning execution's ParentExecutionId. The retry sweep must + // surface it on the CachedCallAttemptContext beside ExecutionId so the + // audit bridge can stamp it on the retry-loop cached rows. + var parentExecutionId = 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(), + parentExecutionId: parentExecutionId); + + await _service.RetryPendingMessagesAsync(); + + var notification = Assert.Single(_observer.Notifications); + Assert.Equal(parentExecutionId, notification.ParentExecutionId); + } + + [Fact] + public async Task Attempt_NullParentExecutionId_SurfacesAsNull() + { + // Non-routed run: the originating script was not spawned by an + // inbound-API request, so no ParentExecutionId is threaded. It must + // surface 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.ParentExecutionId); + } + + [Fact] + public async Task TerminalResolve_CarriesParentExecutionId() + { + // The terminal Delivered notification must also carry the threaded + // ParentExecutionId so the CachedResolve audit row correlates back to + // the spawning inbound-API execution. + var parentExecutionId = 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(), + parentExecutionId: parentExecutionId); + + await _service.RetryPendingMessagesAsync(); + + var notification = Assert.Single(_observer.Notifications); + Assert.Equal(CachedCallAttemptOutcome.Delivered, notification.Outcome); + Assert.Equal(parentExecutionId, notification.ParentExecutionId); + } + // ── Best-effort contract: observer throws must NOT corrupt retry bookkeeping ── [Fact] diff --git a/tests/ScadaLink.StoreAndForward.Tests/StoreAndForwardStorageTests.cs b/tests/ScadaLink.StoreAndForward.Tests/StoreAndForwardStorageTests.cs index 6fed58e..f6a2f0a 100644 --- a/tests/ScadaLink.StoreAndForward.Tests/StoreAndForwardStorageTests.cs +++ b/tests/ScadaLink.StoreAndForward.Tests/StoreAndForwardStorageTests.cs @@ -452,6 +452,141 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable Assert.Equal(message.ExecutionId, retrieved!.ExecutionId); } + // ── Audit Log #23 (ParentExecutionId Task 6): parent_execution_id ── + + [Fact] + public async Task EnqueueAsync_RoundTripsParentExecutionId() + { + // A cached call buffered from an inbound-API-routed script run carries + // the spawning execution's ParentExecutionId; it must survive a persist + // + read-back so the retry loop can stamp it on audit rows. + var parentExecutionId = Guid.NewGuid(); + var message = CreateMessage("parent1", StoreAndForwardCategory.ExternalSystem); + message.ParentExecutionId = parentExecutionId; + + await _storage.EnqueueAsync(message); + + var retrieved = await _storage.GetMessageByIdAsync("parent1"); + Assert.NotNull(retrieved); + Assert.Equal(parentExecutionId, retrieved!.ParentExecutionId); + } + + [Fact] + public async Task EnqueueAsync_NullParentExecutionId_RoundTripsAsNull() + { + // A non-routed run supplies no ParentExecutionId — it must round-trip + // as null rather than throwing or coercing. + var message = CreateMessage("noparent1", StoreAndForwardCategory.ExternalSystem); + Assert.Null(message.ParentExecutionId); + + await _storage.EnqueueAsync(message); + + var retrieved = await _storage.GetMessageByIdAsync("noparent1"); + Assert.NotNull(retrieved); + Assert.Null(retrieved!.ParentExecutionId); + } + + [Fact] + public async Task ParentExecutionId_SurvivesRetrySweepRead() + { + // The retry sweep reads due rows via GetMessagesForRetryAsync; the new + // parent_execution_id field must be present on that read path too — it + // is the path that feeds the CachedCallAttemptContext. + var parentExecutionId = Guid.NewGuid(); + var message = CreateMessage("psweep1", StoreAndForwardCategory.CachedDbWrite); + message.ParentExecutionId = parentExecutionId; + message.LastAttemptAt = null; // due immediately + await _storage.EnqueueAsync(message); + + var due = await _storage.GetMessagesForRetryAsync(); + + var row = Assert.Single(due, m => m.Id == "psweep1"); + Assert.Equal(parentExecutionId, row.ParentExecutionId); + } + + [Fact] + public async Task LegacyRowWithoutParentExecutionIdColumn_ReadsBackAsNull() + { + // Back-compat: a row persisted by a build that pre-dates the + // parent_execution_id column must still deserialize, with + // ParentExecutionId reading back as null. Simulate the pre-Task-6 + // schema (which already has execution_id / source_script from the + // ExecutionId rollout) by recreating the table without + // parent_execution_id, inserting directly, then running InitializeAsync + // which ALTER-adds the column. + 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, + execution_id TEXT, + source_script TEXT + ); + INSERT INTO sf_messages (id, category, target, payload_json, created_at, status) + VALUES ('plegacy1', 0, 'ERP', '{}', '2026-01-01T00:00:00.0000000+00:00', 0);"; + await drop.ExecuteNonQueryAsync(); + } + + // InitializeAsync must additively ALTER-in parent_execution_id without + // disturbing the pre-existing legacy row. + await _storage.InitializeAsync(); + + var retrieved = await _storage.GetMessageByIdAsync("plegacy1"); + Assert.NotNull(retrieved); + Assert.Equal("plegacy1", retrieved!.Id); + Assert.Null(retrieved.ParentExecutionId); + } + + [Fact] + public async Task MalformedParentExecutionId_ReadsBackAsNull_DoesNotAbortRetrySweep() + { + // Defensive read path: a corrupt (non-null, non-GUID) parent_execution_id + // must be treated as "no parent execution id" rather than throwing + // FormatException — a single bad row must not abort the whole + // GetMessagesForRetryAsync sweep. + var goodParent = Guid.NewGuid(); + var good = CreateMessage("pgood1", StoreAndForwardCategory.ExternalSystem); + good.ParentExecutionId = goodParent; + good.LastAttemptAt = null; // due immediately + await _storage.EnqueueAsync(good); + + var bad = CreateMessage("pbad1", StoreAndForwardCategory.ExternalSystem); + bad.ParentExecutionId = 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 parent_execution_id = 'not-a-guid' WHERE id = 'pbad1';"; + await corrupt.ExecuteNonQueryAsync(); + } + + var due = await _storage.GetMessagesForRetryAsync(); + Assert.Null(Assert.Single(due, m => m.Id == "pbad1").ParentExecutionId); + Assert.Equal(goodParent, Assert.Single(due, m => m.Id == "pgood1").ParentExecutionId); + + var retrieved = await _storage.GetMessageByIdAsync("pbad1"); + Assert.NotNull(retrieved); + Assert.Null(retrieved!.ParentExecutionId); + } + private static StoreAndForwardMessage CreateMessage(string id, StoreAndForwardCategory category) { return new StoreAndForwardMessage From d35551efc28df05eb1f5264a6eeb781cf5824645 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 18:11:04 -0400 Subject: [PATCH 10/19] feat(auditlog): NotifyDeliver rows carry the originating ParentExecutionId --- .../Entities/Notifications/Notification.cs | 9 + .../Notification/NotificationMessages.cs | 10 +- .../NotificationOutboxConfiguration.cs | 4 + ...icationOriginParentExecutionId.Designer.cs | 1639 +++++++++++++++++ ..._AddNotificationOriginParentExecutionId.cs | 42 + .../ScadaLinkDbContextModelSnapshot.cs | 3 + .../NotificationOutboxActor.cs | 12 +- .../Scripts/ScriptRuntimeContext.cs | 8 +- .../Entities/NotificationEntityTests.cs | 15 + .../Messages/NotificationMessagesTests.cs | 45 + ...onOriginParentExecutionIdMigrationTests.cs | 71 + .../RepositoryCoverageTests.cs | 23 + ...ficationOutboxActorAttemptEmissionTests.cs | 48 +- .../NotificationOutboxActorIngestTests.cs | 43 +- ...icationOutboxActorTerminalEmissionTests.cs | 48 +- .../Scripts/NotifyHelperTests.cs | 45 +- 16 files changed, 2056 insertions(+), 9 deletions(-) create mode 100644 src/ScadaLink.ConfigurationDatabase/Migrations/20260521220924_AddNotificationOriginParentExecutionId.Designer.cs create mode 100644 src/ScadaLink.ConfigurationDatabase/Migrations/20260521220924_AddNotificationOriginParentExecutionId.cs create mode 100644 tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddNotificationOriginParentExecutionIdMigrationTests.cs diff --git a/src/ScadaLink.Commons/Entities/Notifications/Notification.cs b/src/ScadaLink.Commons/Entities/Notifications/Notification.cs index 916ea02..f9305d0 100644 --- a/src/ScadaLink.Commons/Entities/Notifications/Notification.cs +++ b/src/ScadaLink.Commons/Entities/Notifications/Notification.cs @@ -36,6 +36,15 @@ public class Notification /// submitted before the column existed, or raised outside a script-execution context. /// public Guid? OriginExecutionId { get; set; } + + /// + /// The originating routed script execution's ParentExecutionId (Audit Log #23). + /// Carried from the site on the + /// so the central dispatcher can stamp the same parent id onto its NotifyDeliver + /// audit rows, correlating them with the site-emitted NotifySend row. Null for + /// non-routed runs, or for notifications submitted before the column existed. + /// + public Guid? OriginParentExecutionId { get; set; } public DateTimeOffset SiteEnqueuedAt { get; set; } /// Central ingest time. diff --git a/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs b/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs index ecfb1d6..1c5a868 100644 --- a/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs +++ b/src/ScadaLink.Commons/Messages/Notification/NotificationMessages.cs @@ -11,6 +11,13 @@ namespace ScadaLink.Commons.Messages.Notification; /// NotifyDeliver audit rows. Additive trailing member — null for messages built /// before the field existed, or for notifications raised outside a script execution. /// +/// +/// The originating routed script execution's ParentExecutionId (Audit Log #23). +/// Stamped at Notify.Send time and carried, inside the serialized payload, through +/// the site store-and-forward buffer so the central dispatcher can echo it onto the +/// NotifyDeliver audit rows. Additive trailing member — null for messages built +/// before the field existed, or for non-routed runs. +/// public record NotificationSubmit( string NotificationId, string ListName, @@ -20,7 +27,8 @@ public record NotificationSubmit( string? SourceInstanceId, string? SourceScript, DateTimeOffset SiteEnqueuedAt, - Guid? OriginExecutionId = null); + Guid? OriginExecutionId = null, + Guid? OriginParentExecutionId = null); /// /// Central -> Site: ack sent after the notification row is persisted. diff --git a/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs b/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs index a30859b..cb6e4c2 100644 --- a/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs +++ b/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationOutboxConfiguration.cs @@ -51,6 +51,10 @@ public class NotificationOutboxConfiguration : IEntityTypeConfiguration new { n.Status, n.NextAttemptAt }); builder.HasIndex(n => new { n.SourceSiteId, n.CreatedAt }); diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260521220924_AddNotificationOriginParentExecutionId.Designer.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260521220924_AddNotificationOriginParentExecutionId.Designer.cs new file mode 100644 index 0000000..ce3e031 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260521220924_AddNotificationOriginParentExecutionId.Designer.cs @@ -0,0 +1,1639 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ScadaLink.ConfigurationDatabase; + +#nullable disable + +namespace ScadaLink.ConfigurationDatabase.Migrations +{ + [DbContext(typeof(ScadaLinkDbContext))] + [Migration("20260521220924_AddNotificationOriginParentExecutionId")] + partial class AddNotificationOriginParentExecutionId + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditEvent", b => + { + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetime2"); + + b.Property("Actor") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + + b.Property("DurationMs") + .HasColumnType("int"); + + b.Property("ErrorDetail") + .HasColumnType("nvarchar(max)"); + + b.Property("ErrorMessage") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("ExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("Extra") + .HasColumnType("nvarchar(max)"); + + b.Property("ForwardState") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("ParentExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("PayloadTruncated") + .HasColumnType("bit"); + + b.Property("RequestSummary") + .HasColumnType("nvarchar(max)"); + + b.Property("ResponseSummary") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceInstanceId") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("SourceScript") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("SourceSiteId") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Target") + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.HasKey("EventId", "OccurredAtUtc"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("IX_AuditLog_CorrelationId") + .HasFilter("[CorrelationId] IS NOT NULL"); + + b.HasIndex("EventId") + .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"); + + b.HasIndex("ParentExecutionId") + .HasDatabaseName("IX_AuditLog_ParentExecution") + .HasFilter("[ParentExecutionId] IS NOT NULL"); + + b.HasIndex("SourceSiteId", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Site_Occurred"); + + b.HasIndex("Target", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Target_Occurred") + .HasFilter("[Target] IS NOT NULL"); + + b.HasIndex("Channel", "Status", "OccurredAtUtc") + .IsDescending(false, false, true) + .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred"); + + b.ToTable("AuditLog", (string)null); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("AfterStateJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("User") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("EntityId"); + + b.HasIndex("EntityType"); + + b.HasIndex("Timestamp"); + + b.HasIndex("User"); + + b.ToTable("AuditLogEntries"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.SiteCall", b => + { + b.Property("TrackedOperationId") + .HasMaxLength(36) + .IsUnicode(false) + .HasColumnType("varchar(36)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SourceSite") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Target") + .IsRequired() + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.Property("TerminalAtUtc") + .HasColumnType("datetime2"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("TrackedOperationId"); + + b.HasIndex("SourceSite", "CreatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Source_Created"); + + b.HasIndex("Status", "UpdatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Status_Updated"); + + b.ToTable("SiteCalls", (string)null); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DeployedConfigSnapshots"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ErrorMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.HasIndex("DeploymentId") + .IsUnique(); + + b.HasIndex("InstanceId"); + + b.ToTable("DeploymentRecords"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.SystemArtifactDeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PerSiteStatus") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.ToTable("SystemArtifactDeploymentRecords"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.DatabaseConnectionDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("DatabaseConnectionDefinitions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthConfiguration") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("EndpointUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ExternalSystemDefinitions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalSystemDefinitionId") + .HasColumnType("int"); + + b.Property("HttpMethod") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalSystemDefinitionId", "Name") + .IsUnique(); + + b.ToTable("ExternalSystemMethods"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApprovedApiKeyIds") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Script") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TimeoutSeconds") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiMethods"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentAreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentAreaId"); + + b.HasIndex("SiteId", "ParentAreaId", "Name") + .IsUnique() + .HasFilter("[ParentAreaId] IS NOT NULL"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("UniqueName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("SiteId", "UniqueName") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlarmCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("PriorityLevelOverride") + .HasColumnType("int"); + + b.Property("TriggerConfigurationOverride") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AlarmCanonicalName") + .IsUnique(); + + b.ToTable("InstanceAlarmOverrides"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("OverrideValue") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceAttributeOverrides"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DataConnectionId") + .HasColumnType("int"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DataConnectionId"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceConnectionBindings"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.Notification", b => + { + b.Property("NotificationId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeliveredAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ListName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NextAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("OriginExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("OriginParentExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("ResolvedTargets") + .HasColumnType("nvarchar(max)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SiteEnqueuedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SourceInstanceId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceScript") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceSiteId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TypeData") + .HasColumnType("nvarchar(max)"); + + b.HasKey("NotificationId"); + + b.HasIndex("SourceSiteId", "CreatedAt"); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("NotificationLists"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotificationListId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NotificationListId"); + + b.ToTable("NotificationRecipients"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.SmtpConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ConnectionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("Credentials") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("FromAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MaxConcurrentConnections") + .HasColumnType("int"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.Property("TlsMode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.ToTable("SmtpConfigurations"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Scripts.SharedScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SharedScripts"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.LdapGroupMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("LdapGroupName") + .IsUnique(); + + b.ToTable("LdapGroupMappings"); + + b.HasData( + new + { + Id = 1, + LdapGroupName = "SCADA-Admins", + Role = "Admin" + }, + new + { + Id = 2, + LdapGroupName = "SCADA-Designers", + Role = "Design" + }, + new + { + Id = 3, + LdapGroupName = "SCADA-Deploy-All", + Role = "Deployment" + }, + new + { + Id = 4, + LdapGroupName = "SCADA-Deploy-SiteA", + Role = "Deployment" + }); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupMappingId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.HasIndex("LdapGroupMappingId", "SiteId") + .IsUnique(); + + b.ToTable("SiteScopeRules"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BackupConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("FailoverRetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(3); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PrimaryConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("DataConnections"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("GrpcNodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("GrpcNodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SiteIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SiteIdentifier") + .IsUnique(); + + b.ToTable("Sites"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FolderId") + .HasColumnType("int"); + + b.Property("IsDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OwnerCompositionId") + .HasColumnType("int"); + + b.Property("ParentTemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.HasIndex("Name") + .IsUnique() + .HasFilter("[IsDerived] = 0"); + + b.HasIndex("ParentTemplateId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OnTriggerScriptId") + .HasColumnType("int"); + + b.Property("PriorityLevel") + .HasColumnType("int"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAlarms"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DataSourceReference") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("Value") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAttributes"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateComposition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ComposedTemplateId") + .HasColumnType("int"); + + b.Property("InstanceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ComposedTemplateId"); + + b.HasIndex("TemplateId", "InstanceName") + .IsUnique(); + + b.ToTable("TemplateCompositions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentFolderId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentFolderId", "Name") + .IsUnique() + .HasFilter("[ParentFolderId] IS NOT NULL"); + + b.ToTable("TemplateFolders"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("MinTimeBetweenRuns") + .HasColumnType("time"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateScripts"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.HasOne("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", null) + .WithMany() + .HasForeignKey("ExternalSystemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Area", null) + .WithMany("Children") + .HasForeignKey("ParentAreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Area", null) + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("AlarmOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("AttributeOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.HasOne("ScadaLink.Commons.Entities.Sites.DataConnection", null) + .WithMany() + .HasForeignKey("DataConnectionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("ConnectionBindings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.HasOne("ScadaLink.Commons.Entities.Notifications.NotificationList", null) + .WithMany("Recipients") + .HasForeignKey("NotificationListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b => + { + b.HasOne("ScadaLink.Commons.Entities.Security.LdapGroupMapping", null) + .WithMany() + .HasForeignKey("LdapGroupMappingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b => + { + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ParentTemplateId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAlarm", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Alarms") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAttribute", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Attributes") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateComposition", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ComposedTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Compositions") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateFolder", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("ParentFolderId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateScript", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Scripts") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.Navigation("AlarmOverrides"); + + b.Navigation("AttributeOverrides"); + + b.Navigation("ConnectionBindings"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b => + { + b.Navigation("Recipients"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.Navigation("Alarms"); + + b.Navigation("Attributes"); + + b.Navigation("Compositions"); + + b.Navigation("Scripts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260521220924_AddNotificationOriginParentExecutionId.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260521220924_AddNotificationOriginParentExecutionId.cs new file mode 100644 index 0000000..245e14e --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260521220924_AddNotificationOriginParentExecutionId.cs @@ -0,0 +1,42 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ScadaLink.ConfigurationDatabase.Migrations +{ + /// + /// Adds the OriginParentExecutionId correlation column to the central + /// Notifications table (#21). It carries the originating routed script + /// execution's ParentExecutionId from the site so the dispatcher can echo it + /// onto the NotifyDeliver audit rows (#23), linking them to the routed run's + /// parent. Sibling of OriginExecutionId. + /// + /// The change is purely additive: OriginParentExecutionId uniqueidentifier NULL + /// is added with no default, so the operation is a metadata-only + /// ALTER TABLE … ADD. Unlike AuditLog, the Notifications table is + /// NOT partitioned, so a plain ADD is fine. No index is created — the column is + /// never a query predicate, only copied onto audit events. Historical rows stay + /// NULL. + /// + public partial class AddNotificationOriginParentExecutionId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "OriginParentExecutionId", + table: "Notifications", + type: "uniqueidentifier", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "OriginParentExecutionId", + table: "Notifications"); + } + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs index 47d71da..1f5a5e3 100644 --- a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs @@ -797,6 +797,9 @@ namespace ScadaLink.ConfigurationDatabase.Migrations b.Property("OriginExecutionId") .HasColumnType("uniqueidentifier"); + b.Property("OriginParentExecutionId") + .HasColumnType("uniqueidentifier"); + b.Property("ResolvedTargets") .HasColumnType("nvarchar(max)"); diff --git a/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs b/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs index 2c2af88..2c3284d 100644 --- a/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs +++ b/src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs @@ -492,7 +492,8 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers /// is copied straight from /// so the dispatcher's /// NotifyDeliver rows carry the same per-run id as the site's - /// NotifySend row (Audit Log #23). + /// NotifySend row (Audit Log #23); + /// is likewise copied from . /// private static AuditEvent BuildNotifyDeliverEvent( Notification notification, @@ -525,6 +526,11 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers // rows to the site-emitted NotifySend row for the same run. Null when // the notification was raised outside a script execution. ExecutionId = notification.OriginExecutionId, + // ParentExecutionId (Audit Log #23): the originating routed run's + // parent ExecutionId, carried from the site on NotificationSubmit and + // persisted on the Notification row. Echoing it here links the central + // NotifyDeliver rows to the routed run's parent. Null for non-routed runs. + ParentExecutionId = notification.OriginParentExecutionId, Target = notification.ListName, Status = status, ErrorMessage = errorMessage, @@ -954,6 +960,10 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers // 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, + // OriginParentExecutionId (Audit Log #23): the originating routed run's parent + // ExecutionId, carried from the site so the dispatcher can echo it onto + // NotifyDeliver rows. + OriginParentExecutionId = msg.OriginParentExecutionId, SiteEnqueuedAt = msg.SiteEnqueuedAt, CreatedAt = DateTimeOffset.UtcNow, // Status stays at its Pending default for the dispatch sweep to claim. diff --git a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs index f581645..a8c68fb 100644 --- a/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs +++ b/src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs @@ -1521,7 +1521,13 @@ public class ScriptRuntimeContext // 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); + OriginExecutionId: _executionId, + // OriginParentExecutionId (Audit Log #23): the SAME parent-execution id + // stamped onto this run's NotifySend audit row — the spawning run's id + // for an inbound-API-routed execution, null otherwise. It rides through + // the S&F buffer to central, where the dispatcher echoes it onto the + // NotifyDeliver rows so the central rows carry the routed run's parent id. + OriginParentExecutionId: _parentExecutionId); var payloadJson = JsonSerializer.Serialize(payload); diff --git a/tests/ScadaLink.Commons.Tests/Entities/NotificationEntityTests.cs b/tests/ScadaLink.Commons.Tests/Entities/NotificationEntityTests.cs index d3679da..bb872cd 100644 --- a/tests/ScadaLink.Commons.Tests/Entities/NotificationEntityTests.cs +++ b/tests/ScadaLink.Commons.Tests/Entities/NotificationEntityTests.cs @@ -36,6 +36,21 @@ public class NotificationEntityTests Assert.Equal(executionId, n.OriginExecutionId); } + [Fact] + public void OriginParentExecutionId_DefaultsToNull_AndIsSettable() + { + // Audit Log ParentExecutionId: OriginParentExecutionId carries the + // routed run's parent ExecutionId from the site so the dispatcher can + // echo it onto NotifyDeliver rows. Null for non-routed runs, or for + // notifications submitted before the column existed. + var n = new Notification("id-1", NotificationType.Email, "ops-team", "subj", "body", "SiteA"); + Assert.Null(n.OriginParentExecutionId); + + var parentExecutionId = Guid.NewGuid(); + n.OriginParentExecutionId = parentExecutionId; + Assert.Equal(parentExecutionId, n.OriginParentExecutionId); + } + [Fact] public void Constructor_NullArguments_Throw() { diff --git a/tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs b/tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs index 53b5a15..20c090a 100644 --- a/tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs +++ b/tests/ScadaLink.Commons.Tests/Messages/NotificationMessagesTests.cs @@ -81,6 +81,51 @@ public class NotificationMessagesTests Assert.Equal(executionId, roundTripped!.OriginExecutionId); } + [Fact] + public void NotificationSubmit_OriginParentExecutionId_DefaultsToNull() + { + // Audit Log ParentExecutionId: OriginParentExecutionId is an additive + // trailing member — a submit built without it (old call sites / old + // serialized payloads, or non-routed runs) leaves the id null. + var msg = new NotificationSubmit( + "notif-6", "Operators", "Subject", "Body", + "site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow); + + Assert.Null(msg.OriginParentExecutionId); + } + + [Fact] + public void NotificationSubmit_OriginParentExecutionId_RoundTripsWhenSupplied() + { + var executionId = Guid.NewGuid(); + var parentExecutionId = Guid.NewGuid(); + var msg = new NotificationSubmit( + "notif-7", "Operators", "Subject", "Body", + "site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow, + executionId, parentExecutionId); + + Assert.Equal(parentExecutionId, msg.OriginParentExecutionId); + } + + [Fact] + public void NotificationSubmit_OriginParentExecutionId_SurvivesJsonRoundTrip() + { + // The buffered S&F payload IS a serialized NotificationSubmit; the + // forwarder deserializes it, so OriginParentExecutionId must survive JSON. + var executionId = Guid.NewGuid(); + var parentExecutionId = Guid.NewGuid(); + var msg = new NotificationSubmit( + "notif-8", "Operators", "Subject", "Body", + "site-01", "inst-1", "OnAlarm", DateTimeOffset.UtcNow, + executionId, parentExecutionId); + + var json = System.Text.Json.JsonSerializer.Serialize(msg); + var roundTripped = System.Text.Json.JsonSerializer.Deserialize(json); + + Assert.NotNull(roundTripped); + Assert.Equal(parentExecutionId, roundTripped!.OriginParentExecutionId); + } + [Fact] public void NotificationSubmit_ValueEquality_EqualWhenAllFieldsMatch() { diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddNotificationOriginParentExecutionIdMigrationTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddNotificationOriginParentExecutionIdMigrationTests.cs new file mode 100644 index 0000000..8a7e390 --- /dev/null +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddNotificationOriginParentExecutionIdMigrationTests.cs @@ -0,0 +1,71 @@ +using Xunit; + +namespace ScadaLink.ConfigurationDatabase.Tests.Migrations; + +/// +/// Audit Log ParentExecutionId integration test for the +/// AddNotificationOriginParentExecutionId migration: applies the EF +/// migrations to a freshly-created MSSQL test database on the running +/// infra/mssql container and asserts that the Notifications table carries +/// the new OriginParentExecutionId column as a nullable +/// uniqueidentifier. +/// +/// +/// Unlike AuditLog, the Notifications table is not partitioned, so +/// the column is a plain metadata-only ALTER TABLE … ADD with no index. +/// Tests pair with Skip.IfNot(...) so +/// the runner reports them as Skipped (not Passed) when MSSQL is unreachable. The +/// fixture applies the migrations once at construction time. +/// +public class AddNotificationOriginParentExecutionIdMigrationTests : IClassFixture +{ + private readonly MsSqlMigrationFixture _fixture; + + public AddNotificationOriginParentExecutionIdMigrationTests(MsSqlMigrationFixture fixture) + { + _fixture = fixture; + } + + [SkippableFact] + public async Task AppliesMigration_AddsOriginParentExecutionIdColumn_ToNotifications() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var present = await ScalarAsync( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginParentExecutionId' " + + "AND TABLE_SCHEMA = 'dbo';"); + Assert.Equal(1, present); + } + + [SkippableFact] + public async Task OriginParentExecutionIdColumn_IsNullableUniqueIdentifier() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var dataType = await ScalarAsync( + "SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginParentExecutionId';"); + Assert.Equal("uniqueidentifier", dataType); + + var isNullable = await ScalarAsync( + "SELECT IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginParentExecutionId';"); + Assert.Equal("YES", isNullable); + } + + // --- helpers ------------------------------------------------------------ + + private async Task ScalarAsync(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))!; + } +} diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs index 20fdd50..573b959 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/RepositoryCoverageTests.cs @@ -269,6 +269,7 @@ public class NotificationOutboxConfigurationTests : IDisposable 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 originParentExecutionId = Guid.NewGuid(); var notification = new Notification(id, NotificationType.Email, "Ops List", "High Tank Level", "Tank 4 exceeded the high level threshold.", "site-north") @@ -281,6 +282,7 @@ public class NotificationOutboxConfigurationTests : IDisposable SourceInstanceId = "instance-42", SourceScript = "TankLevelAlarm", OriginExecutionId = originExecutionId, + OriginParentExecutionId = originParentExecutionId, SiteEnqueuedAt = siteEnqueuedAt, CreatedAt = createdAt, LastAttemptAt = lastAttemptAt, @@ -314,6 +316,7 @@ public class NotificationOutboxConfigurationTests : IDisposable Assert.Equal(nextAttemptAt, loaded.NextAttemptAt); Assert.Equal(deliveredAt, loaded.DeliveredAt); Assert.Equal(originExecutionId, loaded.OriginExecutionId); + Assert.Equal(originParentExecutionId, loaded.OriginParentExecutionId); } [Fact] @@ -336,6 +339,26 @@ public class NotificationOutboxConfigurationTests : IDisposable Assert.Null(loaded!.OriginExecutionId); } + [Fact] + public async Task Notification_NullOriginParentExecutionId_RoundTripsAsNull() + { + // Audit Log ParentExecutionId: OriginParentExecutionId is an additive + // nullable column — notifications from non-routed runs (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!.OriginParentExecutionId); + } + [Fact] public async Task Notification_StatusPersistsAsString() { diff --git a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorAttemptEmissionTests.cs b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorAttemptEmissionTests.cs index f594d88..27ebd18 100644 --- a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorAttemptEmissionTests.cs +++ b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorAttemptEmissionTests.cs @@ -95,7 +95,8 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit Guid? notificationId = null, string sourceSite = "site-1", int retryCount = 0, - Guid? originExecutionId = null) + Guid? originExecutionId = null, + Guid? originParentExecutionId = null) { return new Notification( (notificationId ?? Guid.NewGuid()).ToString("D"), @@ -110,6 +111,7 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit SourceInstanceId = "instance-42", SourceScript = "AlarmScript", OriginExecutionId = originExecutionId, + OriginParentExecutionId = originParentExecutionId, }; } @@ -207,6 +209,50 @@ public class NotificationOutboxActorAttemptEmissionTests : TestKit }); } + [Fact] + public void Attempt_CarriesOriginParentExecutionId_AsParentExecutionId() + { + // Audit Log ParentExecutionId: the Attempted NotifyDeliver row must echo + // the notification's OriginParentExecutionId so the central dispatcher's + // rows carry the routed run's parent id. + SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1)); + var parentExecutionId = Guid.NewGuid(); + var notification = MakeNotification(originParentExecutionId: parentExecutionId); + _outboxRepository.GetDueAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .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(parentExecutionId, attempted[0].ParentExecutionId); + }); + } + + [Fact] + public void Attempt_NullOriginParentExecutionId_HasNullParentExecutionId() + { + SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1)); + var notification = MakeNotification(originParentExecutionId: null); + _outboxRepository.GetDueAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .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].ParentExecutionId); + }); + } + [Fact] public void Attempt_TransientFailure_EmitsEvent_StatusAttempted_ErrorMessageSet() { diff --git a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs index 1b8cbab..37812d9 100644 --- a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs +++ b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorIngestTests.cs @@ -40,7 +40,9 @@ public class NotificationOutboxActorIngestTests : TestKit } private static NotificationSubmit MakeSubmit( - string? notificationId = null, Guid? originExecutionId = null) + string? notificationId = null, + Guid? originExecutionId = null, + Guid? originParentExecutionId = null) { return new NotificationSubmit( NotificationId: notificationId ?? Guid.NewGuid().ToString(), @@ -51,7 +53,8 @@ public class NotificationOutboxActorIngestTests : TestKit SourceInstanceId: "instance-42", SourceScript: "AlarmScript", SiteEnqueuedAt: new DateTimeOffset(2026, 5, 19, 8, 30, 0, TimeSpan.Zero), - OriginExecutionId: originExecutionId); + OriginExecutionId: originExecutionId, + OriginParentExecutionId: originParentExecutionId); } [Fact] @@ -121,6 +124,42 @@ public class NotificationOutboxActorIngestTests : TestKit Arg.Any()); } + [Fact] + public void NotificationSubmit_CopiesOriginParentExecutionId_OntoPersistedNotification() + { + // Audit Log ParentExecutionId: the routed run's parent ExecutionId 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(), Arg.Any()) + .Returns(true); + var parentExecutionId = Guid.NewGuid(); + var submit = MakeSubmit(originParentExecutionId: parentExecutionId); + var actor = CreateActor(); + + actor.Tell(submit, TestActor); + + ExpectMsg(); + _repository.Received(1).InsertIfNotExistsAsync( + Arg.Is(n => n.OriginParentExecutionId == parentExecutionId), + Arg.Any()); + } + + [Fact] + public void NotificationSubmit_NullOriginParentExecutionId_PersistsNull() + { + _repository.InsertIfNotExistsAsync(Arg.Any(), Arg.Any()) + .Returns(true); + var submit = MakeSubmit(originParentExecutionId: null); + var actor = CreateActor(); + + actor.Tell(submit, TestActor); + + ExpectMsg(); + _repository.Received(1).InsertIfNotExistsAsync( + Arg.Is(n => n.OriginParentExecutionId == null), + Arg.Any()); + } + [Fact] public void DuplicateSubmit_RepositoryReturnsFalse_StillAcksAccepted() { diff --git a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorTerminalEmissionTests.cs b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorTerminalEmissionTests.cs index 08c8cd1..024b803 100644 --- a/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorTerminalEmissionTests.cs +++ b/tests/ScadaLink.NotificationOutbox.Tests/NotificationOutboxActorTerminalEmissionTests.cs @@ -88,7 +88,8 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit NotificationStatus status = NotificationStatus.Pending, int retryCount = 0, Guid? notificationId = null, - Guid? originExecutionId = null) + Guid? originExecutionId = null, + Guid? originParentExecutionId = null) { return new Notification( (notificationId ?? Guid.NewGuid()).ToString("D"), @@ -102,6 +103,7 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit RetryCount = retryCount, CreatedAt = DateTimeOffset.UtcNow, OriginExecutionId = originExecutionId, + OriginParentExecutionId = originParentExecutionId, }; } @@ -191,6 +193,50 @@ public class NotificationOutboxActorTerminalEmissionTests : TestKit }); } + [Fact] + public void Terminal_Delivered_CarriesOriginParentExecutionId_AsParentExecutionId() + { + // Audit Log ParentExecutionId: the terminal NotifyDeliver row must echo + // the notification's OriginParentExecutionId so the central dispatcher's + // rows carry the routed run's parent id. + SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1)); + var parentExecutionId = Guid.NewGuid(); + var notification = MakeNotification(originParentExecutionId: parentExecutionId); + _outboxRepository.GetDueAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .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(parentExecutionId, delivered[0].ParentExecutionId); + }); + } + + [Fact] + public void Terminal_Delivered_NullOriginParentExecutionId_HasNullParentExecutionId() + { + SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1)); + var notification = MakeNotification(originParentExecutionId: null); + _outboxRepository.GetDueAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .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].ParentExecutionId); + }); + } + [Fact] public void Terminal_Parked_OnPermanentFailure_EmitsEvent_StatusParked() { diff --git a/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs b/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs index bfb66f6..d4f5eca 100644 --- a/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs +++ b/tests/ScadaLink.SiteRuntime.Tests/Scripts/NotifyHelperTests.cs @@ -61,7 +61,8 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable private ScriptRuntimeContext.NotifyHelper CreateHelper( IActorRef siteCommunicationActor, string? sourceScript = null, - Guid? executionId = null) + Guid? executionId = null, + Guid? parentExecutionId = null) { return new ScriptRuntimeContext.NotifyHelper( _saf, @@ -71,7 +72,9 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable sourceScript, TimeSpan.FromSeconds(3), NullLogger.Instance, - executionId ?? Guid.NewGuid()); + executionId ?? Guid.NewGuid(), + auditWriter: null, + parentExecutionId: parentExecutionId); } [Fact] @@ -156,6 +159,44 @@ public class NotifyHelperTests : TestKit, IAsyncLifetime, IDisposable Assert.Equal(executionId, payload!.OriginExecutionId); } + [Fact] + public async Task Send_StampsParentExecutionId_OnTheNotificationSubmitPayload() + { + // Audit Log ParentExecutionId (Task 7): for an inbound-API-routed run, + // Notify.Send must stamp the routed run's parent 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 parent id stamped onto the site-emitted NotifySend row. + var parentExecutionId = Guid.NewGuid(); + var commProbe = CreateTestProbe(); + var notify = CreateHelper(commProbe.Ref, parentExecutionId: parentExecutionId); + + 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(buffered!.PayloadJson); + Assert.NotNull(payload); + Assert.Equal(parentExecutionId, payload!.OriginParentExecutionId); + } + + [Fact] + public async Task Send_NonRoutedRun_LeavesOriginParentExecutionIdNull() + { + // Non-routed runs have no parent execution — OriginParentExecutionId + // stays null on the NotificationSubmit payload. + var commProbe = CreateTestProbe(); + var notify = CreateHelper(commProbe.Ref, parentExecutionId: null); + + 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(buffered!.PayloadJson); + Assert.NotNull(payload); + Assert.Null(payload!.OriginParentExecutionId); + } + [Fact] public async Task Send_WhenHelperHasNoSourceScript_LeavesSourceScriptNull() { From 255dd95cd9e100d7f74e37416435591c772fc241 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 18:21:49 -0400 Subject: [PATCH 11/19] feat(auditlog): GetExecutionTreeAsync recursive execution-chain query --- .../Repositories/IAuditLogRepository.cs | 41 ++++ .../Types/Audit/ExecutionTreeNode.cs | 71 +++++++ .../Repositories/AuditLogRepository.cs | 193 ++++++++++++++++++ .../Central/AuditLogIngestActorTests.cs | 4 + .../Central/AuditLogPurgeActorTests.cs | 4 + .../Central/CentralAuditWriteFailuresTests.cs | 4 + .../SiteAuditReconciliationActorTests.cs | 4 + .../Repositories/AuditLogRepositoryTests.cs | 137 +++++++++++++ .../AuditLog/SiteAuditPushFlowTests.cs | 4 + 9 files changed, 462 insertions(+) create mode 100644 src/ScadaLink.Commons/Types/Audit/ExecutionTreeNode.cs diff --git a/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs b/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs index 36b0d0f..13fd96b 100644 --- a/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs +++ b/src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs @@ -134,4 +134,45 @@ public interface IAuditLogRepository TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default); + + /// + /// Audit Log ParentExecutionId feature (Task 8) — given any + /// in an execution chain, returns the whole + /// chain rooted at the topmost ancestor: one + /// per distinct execution, summarising its AuditLog rows. The Central + /// UI renders the result as a tree. + /// + /// + /// + /// The input id may be any node in the chain — a leaf, the root, or a middle + /// node. The implementation first walks up via + /// ParentExecutionId to find the root, then walks down from + /// the root via a recursive CTE, so the full chain is returned regardless of + /// entry point. + /// + /// + /// The ParentExecutionId graph is a tree (acyclic by construction — + /// each execution is minted fresh and its parent always pre-exists). Both + /// the upward walk and the downward CTE are nonetheless bounded at 32 levels + /// as a guard against corrupt/pathological data: a depth that exceeds the + /// guard raises an error rather than hanging the server. Chains are shallow + /// (1-2 levels typical) so the guard is never reached in practice. + /// + /// + /// A "stub" node — an execution that emitted no rows of its own yet is + /// referenced by a child via ParentExecutionId, or whose rows have + /// been purged — still appears, with + /// = 0. A purged/missing parent simply ends the upward walk. + /// + /// + /// When no AuditLog row carries in + /// either ExecutionId or ParentExecutionId, the result is a + /// single stub node for itself + /// ( = 0) — consistent with the + /// stub-node treatment of any other row-less execution. + /// + /// + Task> GetExecutionTreeAsync( + Guid executionId, + CancellationToken ct = default); } diff --git a/src/ScadaLink.Commons/Types/Audit/ExecutionTreeNode.cs b/src/ScadaLink.Commons/Types/Audit/ExecutionTreeNode.cs new file mode 100644 index 0000000..efce45f --- /dev/null +++ b/src/ScadaLink.Commons/Types/Audit/ExecutionTreeNode.cs @@ -0,0 +1,71 @@ +namespace ScadaLink.Commons.Types.Audit; + +/// +/// One execution within an execution chain returned by +/// . +/// Each node summarises the AuditLog rows sharing a single +/// ; the Central UI renders the set as a tree by +/// joining to a parent node's +/// . +/// +/// +/// +/// Stub nodes. An execution that performed a trust-boundary action but +/// crossed it without emitting any audit row — or whose own rows have been +/// purged — still appears as a node when a child references it via +/// . Such a stub node has +/// = 0, empty /, null +/// /, null timestamps, +/// and a null (a purged/ghost parent leaves no +/// row from which its own parent could be read — the upward walk ends there). +/// +/// +/// and are the distinct sets of +/// the corresponding enum names present across the execution's rows, modelled +/// as of string to mirror how the repository's +/// query filters already pass small bounded sets around. +/// +/// +/// The execution this node summarises. +/// +/// The of the spawning execution, or null for the +/// root (and for stub nodes, whose own parent is unknowable). +/// +/// +/// Number of AuditLog rows carrying this ; 0 for +/// a stub node. +/// +/// +/// Distinct names +/// present across this execution's rows; empty for a stub node. +/// +/// +/// Distinct names +/// present across this execution's rows; empty for a stub node. +/// +/// +/// Source site of the execution's rows when consistent; null for a stub node +/// (or when the rows carry no site). +/// +/// +/// Source instance of the execution's rows when consistent; null for a stub +/// node (or when the rows carry no instance). +/// +/// +/// Earliest OccurredAtUtc across this execution's rows; null for a stub +/// node. +/// +/// +/// Latest OccurredAtUtc across this execution's rows; null for a stub +/// node. +/// +public sealed record ExecutionTreeNode( + Guid ExecutionId, + Guid? ParentExecutionId, + int RowCount, + IReadOnlyList Channels, + IReadOnlyList Statuses, + string? SourceSiteId, + string? SourceInstanceId, + DateTime? FirstOccurredAtUtc, + DateTime? LastOccurredAtUtc); diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs index 2c5cabf..85afb46 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs @@ -555,4 +555,197 @@ VALUES BacklogTotal: 0L, AsOfUtc: anchorUtc); } + + // Hard ceiling on chain depth for both the upward walk and the downward + // recursive CTE. The ParentExecutionId graph is a tree (acyclic by + // construction — each execution is minted fresh, its parent always + // pre-exists), so this is purely a guard against corrupt/pathological data: + // a cycle must surface as a bounded error rather than hang the server. + // Chains are shallow (1-2 levels typical) so the guard is never reached in + // practice. + private const int ExecutionChainMaxDepth = 32; + + /// + /// Audit Log ParentExecutionId (Task 8) — returns the whole execution chain + /// containing , regardless of entry point. + /// + /// + /// + /// Two phases. Walk up: an iterative + /// SELECT TOP 1 ParentExecutionId … WHERE ExecutionId = @cur AND ParentExecutionId IS NOT NULL + /// climbs from the supplied node to the root — the last execution id with no + /// parent. The loop is capped at + /// iterations; a purged/missing parent simply ends the climb early. Walk + /// down: a recursive CTE seeded at the root joins + /// child.ParentExecutionId = parent.ExecutionId to enumerate every + /// descendant, bounded by OPTION (MAXRECURSION 32) — corrupt cyclic + /// data raises a (msg 530) rather than spinning. + /// + /// + /// The chain's full execution-id set is the union of the rows' + /// ExecutionId and their ParentExecutionId, so an execution + /// referenced only as a parent — a "stub" that emitted no rows of its own — + /// is included. The final projection LEFT JOINs that id set back to + /// AuditLog and GROUP BYs, so a stub yields a node with + /// RowCount = 0 and empty/null aggregates. The query is SELECT-only + /// (the audit writer role grants no UPDATE/DELETE — reads are unrestricted). + /// + /// + public async Task> GetExecutionTreeAsync( + Guid executionId, + CancellationToken ct = default) + { + var conn = _context.Database.GetDbConnection(); + var openedHere = false; + if (conn.State != System.Data.ConnectionState.Open) + { + await conn.OpenAsync(ct).ConfigureAwait(false); + openedHere = true; + } + + try + { + // --- Phase 1: walk up to the root --------------------------------- + // Climb ParentExecutionId until a node has no parent (root) or the + // parent execution has no rows of its own (purged/stub — the climb + // cannot continue past a row-less node). The depth cap guards + // against a cycle in corrupt data; a tree never reaches it. + var rootExecutionId = executionId; + for (var depth = 0; depth < ExecutionChainMaxDepth; depth++) + { + Guid? parent; + await using (var upCmd = conn.CreateCommand()) + { + upCmd.CommandText = + "SELECT TOP 1 ParentExecutionId FROM dbo.AuditLog " + + "WHERE ExecutionId = @cur AND ParentExecutionId IS NOT NULL;"; + var pCur = upCmd.CreateParameter(); + pCur.ParameterName = "@cur"; + pCur.Value = rootExecutionId; + upCmd.Parameters.Add(pCur); + + var result = await upCmd.ExecuteScalarAsync(ct).ConfigureAwait(false); + parent = result is null or DBNull ? null : (Guid)result; + } + + if (parent is null) + { + // No parent row for the current node — it is the root (or a + // row-less stub at the top of what survives). Stop climbing. + break; + } + + rootExecutionId = parent.Value; + } + + // --- Phase 2: walk down from the root via a recursive CTE --------- + // Chain : seeded at the root, recursively pulls every distinct + // ExecutionId whose rows carry a ParentExecutionId already + // in the chain. SELECT DISTINCT in the recursive member is + // rejected by SQL Server, so the recursion walks raw rows + // and the outer query de-duplicates. + // ChainIds: the chain's full execution-id set = every ExecutionId in + // Chain UNIONed with every non-null ParentExecutionId — the + // UNION pulls in stub parents that emitted no rows. + // Final : LEFT JOIN ChainIds back to AuditLog and GROUP BY so a + // stub surfaces with RowCount 0 and NULL aggregates. + var nodes = new List(); + await using (var downCmd = conn.CreateCommand()) + { + downCmd.CommandText = @" + WITH Chain AS ( + SELECT CAST(@root AS uniqueidentifier) AS ExecutionId + UNION ALL + SELECT a.ExecutionId + FROM dbo.AuditLog a + INNER JOIN Chain c ON a.ParentExecutionId = c.ExecutionId + WHERE a.ExecutionId IS NOT NULL + ), + ChainIds AS ( + SELECT DISTINCT ExecutionId FROM Chain + UNION + SELECT DISTINCT a.ParentExecutionId + FROM dbo.AuditLog a + INNER JOIN Chain c ON a.ExecutionId = c.ExecutionId + WHERE a.ParentExecutionId IS NOT NULL + ) + SELECT + ids.ExecutionId AS [ExecutionId], + MIN(a.ParentExecutionId) AS [ParentExecutionId], + COUNT(a.EventId) AS [RowCount], + (SELECT STRING_AGG(d.Channel, ',') + FROM (SELECT DISTINCT a2.Channel FROM dbo.AuditLog a2 + WHERE a2.ExecutionId = ids.ExecutionId) d) AS [Channels], + (SELECT STRING_AGG(d.Status, ',') + FROM (SELECT DISTINCT a2.Status FROM dbo.AuditLog a2 + WHERE a2.ExecutionId = ids.ExecutionId) d) AS [Statuses], + MIN(a.SourceSiteId) AS [SourceSiteId], + MIN(a.SourceInstanceId) AS [SourceInstanceId], + MIN(a.OccurredAtUtc) AS [FirstOccurredAtUtc], + MAX(a.OccurredAtUtc) AS [LastOccurredAtUtc] + FROM ChainIds ids + LEFT JOIN dbo.AuditLog a ON a.ExecutionId = ids.ExecutionId + GROUP BY ids.ExecutionId + OPTION (MAXRECURSION 32);"; + + var pRoot = downCmd.CreateParameter(); + pRoot.ParameterName = "@root"; + pRoot.Value = rootExecutionId; + downCmd.Parameters.Add(pRoot); + + await using var reader = await downCmd.ExecuteReaderAsync(ct).ConfigureAwait(false); + while (await reader.ReadAsync(ct).ConfigureAwait(false)) + { + var nodeExecutionId = reader.GetGuid(0); + Guid? parentExecutionId = reader.IsDBNull(1) ? null : reader.GetGuid(1); + var rowCount = reader.GetInt32(2); + var channels = SplitAggregate(reader.IsDBNull(3) ? null : reader.GetString(3)); + var statuses = SplitAggregate(reader.IsDBNull(4) ? null : reader.GetString(4)); + var sourceSiteId = reader.IsDBNull(5) ? null : reader.GetString(5); + var sourceInstanceId = reader.IsDBNull(6) ? null : reader.GetString(6); + DateTime? firstOccurred = reader.IsDBNull(7) ? null : reader.GetDateTime(7); + DateTime? lastOccurred = reader.IsDBNull(8) ? null : reader.GetDateTime(8); + + nodes.Add(new ExecutionTreeNode( + ExecutionId: nodeExecutionId, + ParentExecutionId: parentExecutionId, + RowCount: rowCount, + Channels: channels, + Statuses: statuses, + SourceSiteId: sourceSiteId, + SourceInstanceId: sourceInstanceId, + FirstOccurredAtUtc: firstOccurred, + LastOccurredAtUtc: lastOccurred)); + } + } + + return nodes; + } + finally + { + if (openedHere) + { + await conn.CloseAsync().ConfigureAwait(false); + } + } + } + + /// + /// Splits a STRING_AGG comma-joined value into a distinct, ordered + /// list. A null/empty aggregate (a stub node with no rows) yields an empty + /// list rather than a single empty string. + /// + private static IReadOnlyList SplitAggregate(string? aggregate) + { + if (string.IsNullOrEmpty(aggregate)) + { + return Array.Empty(); + } + + return aggregate + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Distinct(StringComparer.Ordinal) + .OrderBy(v => v, StringComparer.Ordinal) + .ToArray(); + } } diff --git a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs index 51a0bb7..32a6606 100644 --- a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogIngestActorTests.cs @@ -224,5 +224,9 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture GetKpiSnapshotAsync( TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => _inner.GetKpiSnapshotAsync(window, nowUtc, ct); + + public Task> GetExecutionTreeAsync( + Guid executionId, CancellationToken ct = default) => + _inner.GetExecutionTreeAsync(executionId, ct); } } diff --git a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs index 241b720..290ff16 100644 --- a/tests/ScadaLink.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs @@ -82,6 +82,10 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture GetKpiSnapshotAsync( TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow)); + + public Task> GetExecutionTreeAsync( + Guid executionId, CancellationToken ct = default) => + Task.FromResult>(Array.Empty()); } private IServiceProvider BuildScopedProvider(IAuditLogRepository repo) diff --git a/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs index b4d3569..4ad28a8 100644 --- a/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs @@ -51,6 +51,10 @@ public class CentralAuditWriteFailuresTests : TestKit public Task GetKpiSnapshotAsync( TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow)); + + public Task> GetExecutionTreeAsync( + Guid executionId, CancellationToken ct = default) => + Task.FromResult>(Array.Empty()); } /// diff --git a/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs b/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs index 87b5024..f8b3b49 100644 --- a/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs @@ -97,6 +97,10 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture GetKpiSnapshotAsync( TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow)); + + public Task> GetExecutionTreeAsync( + Guid executionId, CancellationToken ct = default) => + Task.FromResult>(Array.Empty()); } /// diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs index 01ab04d..f4ddc4a 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs @@ -746,6 +746,143 @@ public class AuditLogRepositoryTests : IClassFixture Assert.Equal(nowUtc, snapshot.AsOfUtc); } + // ------------------------------------------------------------------------ + // Audit Log ParentExecutionId (Task 8): GetExecutionTreeAsync + // ------------------------------------------------------------------------ + // + // GetExecutionTreeAsync walks UP from any node to the chain root, then walks + // DOWN via a recursive CTE, returning one ExecutionTreeNode per distinct + // execution in the chain. These tests verify the observable behaviour: + // * a multi-level chain returns the full set regardless of entry node + // * a parent referenced only via ParentExecutionId (no rows of its own) + // still surfaces, as a RowCount = 0 stub node + // * pathological cyclic data is bounded by the MAXRECURSION guard and + // surfaces a SqlException rather than hanging + + [SkippableFact] + public async Task GetExecutionTree_MultiLevelChain() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + // A 3-level chain: root -> mid -> leaf. Each execution emits two rows so + // RowCount aggregation is exercised; the child rows carry the parent's + // ExecutionId as ParentExecutionId. + var rootExec = Guid.NewGuid(); + var midExec = Guid.NewGuid(); + var leafExec = Guid.NewGuid(); + + var t0 = new DateTime(2026, 10, 5, 9, 0, 0, DateTimeKind.Utc); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, executionId: rootExec)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), executionId: rootExec)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), executionId: midExec, parentExecutionId: rootExec)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(3), executionId: midExec, parentExecutionId: rootExec)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(4), executionId: leafExec, parentExecutionId: midExec)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(5), executionId: leafExec, parentExecutionId: midExec)); + + var expected = new[] { rootExec, midExec, leafExec }; + + // Entry point must not matter: leaf, middle node, and root all yield the + // same full chain. + foreach (var entry in new[] { leafExec, midExec, rootExec }) + { + var tree = await repo.GetExecutionTreeAsync(entry); + + Assert.Equal(3, tree.Count); + Assert.True( + expected.ToHashSet().SetEquals(tree.Select(n => n.ExecutionId)), + $"entry {entry} did not return the full chain"); + + var root = tree.Single(n => n.ExecutionId == rootExec); + var mid = tree.Single(n => n.ExecutionId == midExec); + var leaf = tree.Single(n => n.ExecutionId == leafExec); + + Assert.Null(root.ParentExecutionId); + Assert.Equal(rootExec, mid.ParentExecutionId); + Assert.Equal(midExec, leaf.ParentExecutionId); + + Assert.Equal(2, root.RowCount); + Assert.Equal(2, mid.RowCount); + Assert.Equal(2, leaf.RowCount); + } + } + + [SkippableFact] + public async Task GetExecutionTree_StubParentNode() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + // The parent execution emitted no rows of its own (it performed no + // trust-boundary action, or its rows were purged). Only the child has + // rows, and they reference the parent via ParentExecutionId. The parent + // must still surface as a node — a RowCount = 0 stub. + var stubParentExec = Guid.NewGuid(); + var childExec = Guid.NewGuid(); + + var t0 = new DateTime(2026, 10, 6, 9, 0, 0, DateTimeKind.Utc); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, executionId: childExec, parentExecutionId: stubParentExec)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), executionId: childExec, parentExecutionId: stubParentExec)); + + // Entering by the child must surface BOTH the child and the stub parent. + var tree = await repo.GetExecutionTreeAsync(childExec); + + Assert.Equal(2, tree.Count); + + var stub = tree.Single(n => n.ExecutionId == stubParentExec); + var child = tree.Single(n => n.ExecutionId == childExec); + + // The stub node: no rows, empty aggregate sets, null parent and timestamps. + Assert.Equal(0, stub.RowCount); + Assert.Empty(stub.Channels); + Assert.Empty(stub.Statuses); + Assert.Null(stub.ParentExecutionId); + Assert.Null(stub.FirstOccurredAtUtc); + Assert.Null(stub.LastOccurredAtUtc); + + // The child node carries its rows and points at the stub parent. + Assert.Equal(2, child.RowCount); + Assert.Equal(stubParentExec, child.ParentExecutionId); + } + + [SkippableFact] + public async Task GetExecutionTree_RespectsMaxRecursion() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + await using var context = CreateContext(); + var repo = new AuditLogRepository(context); + + // Pathological cyclic data: A's rows point at B as parent, B's rows + // point back at A. The ParentExecutionId graph is acyclic by + // construction in production, but corrupt data must not hang the + // server. The downward recursive CTE's OPTION (MAXRECURSION 32) raises + // a SqlException when the cycle exceeds the guard; the method surfaces + // it rather than spinning forever. + var execA = Guid.NewGuid(); + var execB = Guid.NewGuid(); + + var t0 = new DateTime(2026, 10, 7, 9, 0, 0, DateTimeKind.Utc); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, executionId: execA, parentExecutionId: execB)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), executionId: execB, parentExecutionId: execA)); + + // The call must complete (throw) quickly, not hang. A generous 30s + // ceiling distinguishes "bounded failure" from "infinite loop". + var call = repo.GetExecutionTreeAsync(execA); + var completed = await Task.WhenAny(call, Task.Delay(TimeSpan.FromSeconds(30))); + Assert.Same(call, completed); + + // MAXRECURSION exceeded surfaces as a SqlException — bounded, not a hang. + await Assert.ThrowsAsync(() => call); + } + private async Task ScalarAsync(ScadaLinkDbContext context, string sql) { var conn = context.Database.GetDbConnection(); diff --git a/tests/ScadaLink.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs b/tests/ScadaLink.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs index 05b2693..be01f04 100644 --- a/tests/ScadaLink.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs +++ b/tests/ScadaLink.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs @@ -96,6 +96,10 @@ public class SiteAuditPushFlowTests : TestKit public Task GetKpiSnapshotAsync( TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => throw new NotSupportedException(); + + public Task> GetExecutionTreeAsync( + Guid executionId, CancellationToken ct = default) + => throw new NotSupportedException(); } private static AuditEvent NewPendingEvent(Guid id) => new() From 252bf0a9704c4ea9dd627d2a4e74ddb2fad0ddbb Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 18:29:48 -0400 Subject: [PATCH 12/19] refactor(auditlog): GetExecutionTreeAsync recurses over a distinct edge set --- .../Repositories/AuditLogRepository.cs | 95 ++++++++++++------- .../Repositories/AuditLogRepositoryTests.cs | 55 +++++++++-- 2 files changed, 110 insertions(+), 40 deletions(-) diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs index 85afb46..76d4b47 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs @@ -576,18 +576,22 @@ VALUES /// climbs from the supplied node to the root — the last execution id with no /// parent. The loop is capped at /// iterations; a purged/missing parent simply ends the climb early. Walk - /// down: a recursive CTE seeded at the root joins - /// child.ParentExecutionId = parent.ExecutionId to enumerate every - /// descendant, bounded by OPTION (MAXRECURSION 32) — corrupt cyclic - /// data raises a (msg 530) rather than spinning. + /// down: a recursive CTE over a DISTINCT + /// (ExecutionId, ParentExecutionId) edge set, seeded at the root edge + /// and joining edge.ParentExecutionId = chain.ExecutionId to + /// enumerate every descendant. Recursing over edges rather than raw rows + /// keeps the recursion one path wide per execution. It is bounded by + /// OPTION (MAXRECURSION 32) — corrupt cyclic data raises a + /// (msg 530) rather than spinning. /// /// - /// The chain's full execution-id set is the union of the rows' - /// ExecutionId and their ParentExecutionId, so an execution - /// referenced only as a parent — a "stub" that emitted no rows of its own — - /// is included. The final projection LEFT JOINs that id set back to - /// AuditLog and GROUP BYs, so a stub yields a node with - /// RowCount = 0 and empty/null aggregates. The query is SELECT-only + /// The chain's full execution-id set is every edge's ExecutionId + /// unioned with its non-null ParentExecutionId, so an execution + /// referenced only as a parent — a "stub" that emitted no rows of its own, + /// and therefore owns no edge of its own — is still included. The final + /// projection LEFT JOINs that id set back to AuditLog and + /// GROUP BYs, so a stub yields a node with RowCount = 0 and + /// empty/null aggregates. The query is SELECT-only /// (the audit writer role grants no UPDATE/DELETE — reads are unrestricted). /// /// @@ -639,36 +643,61 @@ VALUES } // --- Phase 2: walk down from the root via a recursive CTE --------- - // Chain : seeded at the root, recursively pulls every distinct - // ExecutionId whose rows carry a ParentExecutionId already - // in the chain. SELECT DISTINCT in the recursive member is - // rejected by SQL Server, so the recursion walks raw rows - // and the outer query de-duplicates. - // ChainIds: the chain's full execution-id set = every ExecutionId in - // Chain UNIONed with every non-null ParentExecutionId — the - // UNION pulls in stub parents that emitted no rows. - // Final : LEFT JOIN ChainIds back to AuditLog and GROUP BY so a - // stub surfaces with RowCount 0 and NULL aggregates. + // Edges : a non-recursive, DISTINCT (ExecutionId, ParentExecutionId) + // edge set distilled from AuditLog. Recursing over edges + // instead of raw rows means an execution with N audit rows + // contributes ONE recursion path, not N — MAXRECURSION + // bounds depth, not per-level width, so the raw-row form + // could fan out badly. One edge per execution because all + // rows of an execution share a single ParentExecutionId + // (see the MIN(...) note on the final projection). + // Chain : seeded at the root edge, recursively joins each edge whose + // ParentExecutionId is an ExecutionId already in the chain. + // Each edge carries its own ParentExecutionId, so the chain + // of edges already surfaces every execution id in the tree + // — including a row-less stub parent, which appears as the + // ParentExecutionId of its child's edge. No separate + // union-back CTE is needed. + // Final : collect every distinct execution id reachable from the + // chain (each edge's ExecutionId plus its non-null + // ParentExecutionId), LEFT JOIN back to AuditLog and + // GROUP BY so a stub parent — which owns no edge of its own + // because it emitted no rows — still surfaces as a node with + // RowCount 0 and NULL aggregates. var nodes = new List(); await using (var downCmd = conn.CreateCommand()) { - downCmd.CommandText = @" - WITH Chain AS ( - SELECT CAST(@root AS uniqueidentifier) AS ExecutionId + downCmd.CommandText = $@" + WITH Edges AS ( + SELECT DISTINCT ExecutionId, ParentExecutionId + FROM dbo.AuditLog + WHERE ExecutionId IS NOT NULL + ), + Chain AS ( + -- Anchor: the root execution id, seeded as a literal so + -- it is present even when the root is a row-less stub + -- (a purged/no-action parent owns no edge of its own). + -- The root is parentless by construction — the upward + -- walk stopped there — so its ParentExecutionId is NULL. + SELECT CAST(@root AS uniqueidentifier) AS ExecutionId, + CAST(NULL AS uniqueidentifier) AS ParentExecutionId UNION ALL - SELECT a.ExecutionId - FROM dbo.AuditLog a - INNER JOIN Chain c ON a.ParentExecutionId = c.ExecutionId - WHERE a.ExecutionId IS NOT NULL + SELECT e.ExecutionId, e.ParentExecutionId + FROM Edges e + INNER JOIN Chain c ON e.ParentExecutionId = c.ExecutionId ), ChainIds AS ( - SELECT DISTINCT ExecutionId FROM Chain + SELECT ExecutionId FROM Chain UNION - SELECT DISTINCT a.ParentExecutionId - FROM dbo.AuditLog a - INNER JOIN Chain c ON a.ExecutionId = c.ExecutionId - WHERE a.ParentExecutionId IS NOT NULL + SELECT ParentExecutionId FROM Chain + WHERE ParentExecutionId IS NOT NULL ) + -- ParentExecutionId / SourceSiteId / SourceInstanceId are + -- derived via MIN: every audit row of one execution carries + -- the SAME ParentExecutionId (and source identity) — it is + -- stamped once per script run / inbound request — so MIN + -- simply picks that one shared value, it is not collapsing a + -- genuine disagreement across rows. SELECT ids.ExecutionId AS [ExecutionId], MIN(a.ParentExecutionId) AS [ParentExecutionId], @@ -686,7 +715,7 @@ VALUES FROM ChainIds ids LEFT JOIN dbo.AuditLog a ON a.ExecutionId = ids.ExecutionId GROUP BY ids.ExecutionId - OPTION (MAXRECURSION 32);"; + OPTION (MAXRECURSION {ExecutionChainMaxDepth});"; var pRoot = downCmd.CreateParameter(); pRoot.ParameterName = "@root"; diff --git a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs index f4ddc4a..1d7e337 100644 --- a/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs +++ b/tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs @@ -770,18 +770,28 @@ public class AuditLogRepositoryTests : IClassFixture // A 3-level chain: root -> mid -> leaf. Each execution emits two rows so // RowCount aggregation is exercised; the child rows carry the parent's - // ExecutionId as ParentExecutionId. + // ExecutionId as ParentExecutionId. Each execution is given a DISTINCT + // channel, and its two rows carry DISTINCT statuses and timestamps, so + // the per-node Channels/Statuses sets and the FirstOccurred/LastOccurred + // span are meaningfully asserted (not all-defaults). var rootExec = Guid.NewGuid(); var midExec = Guid.NewGuid(); var leafExec = Guid.NewGuid(); var t0 = new DateTime(2026, 10, 5, 9, 0, 0, DateTimeKind.Utc); - await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, executionId: rootExec)); - await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), executionId: rootExec)); - await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), executionId: midExec, parentExecutionId: rootExec)); - await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(3), executionId: midExec, parentExecutionId: rootExec)); - await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(4), executionId: leafExec, parentExecutionId: midExec)); - await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(5), executionId: leafExec, parentExecutionId: midExec)); + var rootT0 = t0; + var rootT1 = t0.AddMinutes(1); + var midT0 = t0.AddMinutes(2); + var midT1 = t0.AddMinutes(3); + var leafT0 = t0.AddMinutes(4); + var leafT1 = t0.AddMinutes(5); + + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: rootT0, channel: AuditChannel.ApiOutbound, status: AuditStatus.Submitted, executionId: rootExec)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: rootT1, channel: AuditChannel.ApiOutbound, status: AuditStatus.Delivered, executionId: rootExec)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: midT0, channel: AuditChannel.DbOutbound, status: AuditStatus.Submitted, executionId: midExec, parentExecutionId: rootExec)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: midT1, channel: AuditChannel.DbOutbound, status: AuditStatus.Failed, executionId: midExec, parentExecutionId: rootExec)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: leafT0, channel: AuditChannel.Notification, status: AuditStatus.Submitted, executionId: leafExec, parentExecutionId: midExec)); + await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: leafT1, channel: AuditChannel.Notification, status: AuditStatus.Parked, executionId: leafExec, parentExecutionId: midExec)); var expected = new[] { rootExec, midExec, leafExec }; @@ -807,6 +817,37 @@ public class AuditLogRepositoryTests : IClassFixture Assert.Equal(2, root.RowCount); Assert.Equal(2, mid.RowCount); Assert.Equal(2, leaf.RowCount); + + // Each populated node aggregates its own rows' channels and + // statuses — distinct per execution, so a regression that mixes + // executions or drops the per-id aggregate would be caught. + Assert.Equal( + new[] { nameof(AuditChannel.ApiOutbound) }, + root.Channels); + Assert.Equal( + new[] { nameof(AuditChannel.DbOutbound) }, + mid.Channels); + Assert.Equal( + new[] { nameof(AuditChannel.Notification) }, + leaf.Channels); + + Assert.True( + new[] { nameof(AuditStatus.Submitted), nameof(AuditStatus.Delivered) } + .ToHashSet().SetEquals(root.Statuses)); + Assert.True( + new[] { nameof(AuditStatus.Submitted), nameof(AuditStatus.Failed) } + .ToHashSet().SetEquals(mid.Statuses)); + Assert.True( + new[] { nameof(AuditStatus.Submitted), nameof(AuditStatus.Parked) } + .ToHashSet().SetEquals(leaf.Statuses)); + + // Each populated node's timestamp span covers exactly its two rows. + Assert.Equal(rootT0, root.FirstOccurredAtUtc); + Assert.Equal(rootT1, root.LastOccurredAtUtc); + Assert.Equal(midT0, mid.FirstOccurredAtUtc); + Assert.Equal(midT1, mid.LastOccurredAtUtc); + Assert.Equal(leafT0, leaf.FirstOccurredAtUtc); + Assert.Equal(leafT1, leaf.LastOccurredAtUtc); } } From 0b5723b77759190cda3a06531ba3ab9e3ba9e1d9 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 18:38:02 -0400 Subject: [PATCH 13/19] feat(centralui): ParentExecutionId column, filter and parent drill-in on the Audit Log page --- .../Audit/AuditExportEndpoints.cs | 8 ++ .../Audit/AuditDrilldownDrawer.razor | 11 +++ .../Audit/AuditDrilldownDrawer.razor.cs | 20 ++++- .../Components/Audit/AuditFilterBar.razor | 10 +++ .../Components/Audit/AuditFilterBar.razor.cs | 1 + .../Components/Audit/AuditQueryModel.cs | 15 ++++ .../Components/Audit/AuditResultsGrid.razor | 12 +++ .../Audit/AuditResultsGrid.razor.cs | 4 +- .../Pages/Audit/AuditLogPage.razor.cs | 23 +++++- .../Audit/AuditDataSeeder.cs | 6 +- .../Audit/AuditLogPageTests.cs | 79 +++++++++++++++++++ .../Audit/AuditExportEndpointsTests.cs | 23 ++++++ .../Audit/AuditDrilldownDrawerTests.cs | 45 +++++++++++ .../Components/Audit/AuditFilterBarTests.cs | 37 +++++++++ .../Components/Audit/AuditResultsGridTests.cs | 49 +++++++++++- .../Pages/AuditLogPageExportUrlTests.cs | 14 ++++ .../Pages/AuditLogPageScaffoldTests.cs | 39 +++++++++ 17 files changed, 387 insertions(+), 9 deletions(-) diff --git a/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs b/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs index 70b2bc3..a497dcb 100644 --- a/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs +++ b/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs @@ -112,6 +112,13 @@ public static class AuditExportEndpoints executionId = parsedExec; } + Guid? parentExecutionId = null; + if (query.TryGetValue("parentExecutionId", out var parentExecValues) + && Guid.TryParse(parentExecValues.ToString(), out var parsedParentExec)) + { + parentExecutionId = parsedParentExec; + } + DateTime? fromUtc = ParseUtcDate(query, "from"); DateTime? toUtc = ParseUtcDate(query, "to"); @@ -124,6 +131,7 @@ public static class AuditExportEndpoints Actor: actor, CorrelationId: correlationId, ExecutionId: executionId, + ParentExecutionId: parentExecutionId, FromUtc: fromUtc, ToUtc: toUtc); } diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor b/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor index ff31789..1c3ea16 100644 --- a/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor @@ -58,6 +58,9 @@
ExecutionId
@(Event.ExecutionId?.ToString() ?? "—")
+
ParentExecutionId
+
@(Event.ParentExecutionId?.ToString() ?? "—")
+
OccurredAtUtc
@FormatTimestamp(Event.OccurredAtUtc)
@@ -162,6 +165,14 @@ View this execution } + @if (Event.ParentExecutionId is not null) + { + + }
@@ -291,6 +294,21 @@ public partial class AuditDrilldownDrawer Navigation.NavigateTo(uri); } + /// + /// Drill-in to the spawner execution: a routed (child) row carries a non-null + /// . Navigates to + /// /audit/log?executionId={ParentExecutionId} so the user sees the + /// spawner execution's own rows — the parent's id becomes the ?executionId= + /// drill-in target. The button is only rendered when + /// is non-null, so this is total. + /// + private void ViewParentExecution() + { + if (Event?.ParentExecutionId is not { } parentExec) return; + var uri = $"/audit/log?executionId={parentExec}"; + Navigation.NavigateTo(uri); + } + /// /// Build a cURL command from an audit event. The URL comes from /// Target; when the RequestSummary parses as diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor b/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor index 6d4bb29..0c06025 100644 --- a/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor @@ -127,6 +127,16 @@ placeholder="paste GUID…" @bind="_model.ExecutionId" /> + @* ParentExecutionId is an exact-match Guid filter — the operator pastes + the spawner execution's id to find every run it spawned. Lax-parsed + in ToFilter, exactly like ExecutionId above. *@ +
+ + +
+
public string ExecutionId { get; set; } = string.Empty; + /// + /// Paste-in ParentExecutionId filter — the operator pastes the spawner + /// execution's Guid to find every run it spawned. Stored as free text; + /// lax-parses it through + /// so a blank or unparseable + /// value simply yields no constraint, mirroring . + /// + public string ParentExecutionId { get; set; } = string.Empty; + public bool ErrorsOnly { get; set; } /// @@ -128,6 +137,11 @@ public sealed class AuditQueryModel ? parsedExecutionId : null; + // Same lax-parse contract for the pasted ParentExecutionId. + Guid? parentExecutionId = Guid.TryParse(ParentExecutionId, out var parsedParentExecutionId) + ? parsedParentExecutionId + : null; + return new AuditLogQueryFilter( Channels: Channels.Count > 0 ? Channels.ToArray() : null, Kinds: Kinds.Count > 0 ? Kinds.ToArray() : null, @@ -137,6 +151,7 @@ public sealed class AuditQueryModel Actor: string.IsNullOrWhiteSpace(ActorSearch) ? null : ActorSearch.Trim(), CorrelationId: null, ExecutionId: executionId, + ParentExecutionId: parentExecutionId, FromUtc: fromUtc, ToUtc: toUtc); } diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor index 2e5c692..cde0f35 100644 --- a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor @@ -132,6 +132,18 @@ } break; + case "ParentExecutionId": + @if (row.ParentExecutionId is { } parentExecutionId) + { + @ShortGuid(parentExecutionId) + } + else + { + + } + break; case "DurationMs": @(row.DurationMs?.ToString() ?? "—") break; diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs index 1303628..94c9611 100644 --- a/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor.cs @@ -11,7 +11,8 @@ namespace ScadaLink.CentralUI.Components.Audit; /// Keyset-paged results grid for the central Audit Log page (#23 M7-T3). /// 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 +/// ErrorMessage — plus the ExecutionId per-run correlation column and the +/// ParentExecutionId spawner-correlation column. Talks to /// /// — never to IAuditLogRepository directly — so tests can stub the data /// source without standing up EF Core. @@ -123,6 +124,7 @@ public partial class AuditResultsGrid : IAsyncDisposable ("Target", "Target"), ("Actor", "Actor"), ("ExecutionId", "ExecutionId"), + ("ParentExecutionId", "ParentExecutionId"), ("DurationMs", "DurationMs"), ("HttpStatus", "HttpStatus"), ("ErrorMessage", "ErrorMessage"), diff --git a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs index 9fb1d42..56a1227 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs +++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs @@ -23,7 +23,9 @@ namespace ScadaLink.CentralUI.Components.Pages.Audit; /// ?instance= are read on initialization. Bundle E (M7-T13) extends /// this with ?status= so the Health-dashboard Audit error-rate tile can /// drill in to ?status=Failed. The ExecutionId follow-up adds -/// ?executionId= for the "View this execution" drill-in. When any param is present we allocate a +/// ?executionId= for the "View this execution" drill-in, and the +/// ParentExecutionId follow-up adds ?parentExecutionId= for the +/// "View parent execution" drill-in. When any param is present we allocate a /// fresh and assign it to /// , which kicks the results grid into auto-load /// without the user clicking Apply. Unknown values (e.g. an invalid enum name) @@ -71,6 +73,15 @@ public partial class AuditLogPage executionId = parsedExec; } + // ?parentExecutionId= constrains to runs spawned by a given execution. + // Lax-parsed like ?executionId=: an unparseable value is silently dropped. + Guid? parentExecutionId = null; + if (query.TryGetValue("parentExecutionId", out var parentExecValues) + && Guid.TryParse(parentExecValues.ToString(), out var parsedParentExec)) + { + parentExecutionId = parsedParentExec; + } + string? target = null; if (query.TryGetValue("target", out var targetValues)) { @@ -128,7 +139,8 @@ 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 && executionId is null && target is null && actor is null + if (correlationId is null && executionId is null && parentExecutionId is null + && target is null && actor is null && sites is null && channels is null && kinds is null && statuses is null) { return; @@ -142,7 +154,8 @@ public partial class AuditLogPage Target: target, Actor: actor, CorrelationId: correlationId, - ExecutionId: executionId); + ExecutionId: executionId, + ParentExecutionId: parentExecutionId); } /// @@ -252,6 +265,10 @@ public partial class AuditLogPage { parts.Add(new("executionId", exec.ToString())); } + if (filter.ParentExecutionId is { } parentExec) + { + parts.Add(new("parentExecutionId", parentExec.ToString())); + } if (filter.FromUtc is { } from) { parts.Add(new("from", from.ToString("O", CultureInfo.InvariantCulture))); diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditDataSeeder.cs b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditDataSeeder.cs index 4c2480d..0edcd95 100644 --- a/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditDataSeeder.cs +++ b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditDataSeeder.cs @@ -66,6 +66,7 @@ internal static class AuditDataSeeder string? actor = null, Guid? correlationId = null, Guid? executionId = null, + Guid? parentExecutionId = null, int? httpStatus = null, int? durationMs = null, string? errorMessage = null, @@ -77,12 +78,12 @@ internal static class AuditDataSeeder const string sql = @" INSERT INTO [AuditLog] ([EventId], [OccurredAtUtc], [IngestedAtUtc], [Channel], [Kind], [CorrelationId], - [ExecutionId], [SourceSiteId], [SourceInstanceId], [SourceScript], [Actor], [Target], + [ExecutionId], [ParentExecutionId], [SourceSiteId], [SourceInstanceId], [SourceScript], [Actor], [Target], [Status], [HttpStatus], [DurationMs], [ErrorMessage], [ErrorDetail], [RequestSummary], [ResponseSummary], [PayloadTruncated], [Extra], [ForwardState]) VALUES (@eventId, @occurredAtUtc, SYSUTCDATETIME(), @channel, @kind, @correlationId, - @executionId, @sourceSiteId, NULL, NULL, @actor, @target, + @executionId, @parentExecutionId, @sourceSiteId, NULL, NULL, @actor, @target, @status, @httpStatus, @durationMs, @errorMessage, NULL, @requestSummary, @responseSummary, 0, @extra, NULL);"; @@ -96,6 +97,7 @@ VALUES cmd.Parameters.AddWithValue("@kind", kind); cmd.Parameters.AddWithValue("@correlationId", (object?)correlationId ?? DBNull.Value); cmd.Parameters.AddWithValue("@executionId", (object?)executionId ?? DBNull.Value); + cmd.Parameters.AddWithValue("@parentExecutionId", (object?)parentExecutionId ?? 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); diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs index f066cdb..4fefe5d 100644 --- a/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs +++ b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs @@ -27,6 +27,10 @@ namespace ScadaLink.CentralUI.PlaywrightTests.Audit; /// DrillInFromExecutionId_LandsOnAuditLogWithFilterContext — the /// ?executionId= drill-in (the drawer's "View this execution" action) /// auto-loads the grid filtered by ExecutionId. +/// DrillInFromParentExecution_FiltersGridToSpawnerExecution — the +/// drawer's "View parent execution" action on a spawned (child) row drills in +/// to ?executionId={ParentExecutionId}, auto-loading the spawner's +/// rows. /// NotificationsPage_HasViewAuditHistoryLink_WhenNotificationsExist — /// the report page wires drill-in links when notifications are present. /// ExportCsv_LinkIsVisibleAndDownloads — Export CSV button gated on @@ -350,6 +354,81 @@ public class AuditLogPageTests } } + [Fact] + public async Task DrillInFromParentExecution_FiltersGridToSpawnerExecution() + { + // The drawer's "View parent execution" action navigates a routed (child) + // row to /audit/log?executionId={ParentExecutionId}. We seed a spawner row + // (its ExecutionId == the parent id) and a child row (ParentExecutionId + // pointing at the spawner), open the child's drawer, click the action, and + // assert the grid auto-loads filtered to the spawner's own rows. + 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/parent-exec-drill-in/{runId}/"; + var parentExecutionId = Guid.NewGuid(); + var spawnerEventId = Guid.NewGuid(); + var childEventId = Guid.NewGuid(); + var now = DateTime.UtcNow; + + try + { + // The spawner execution's own row — carries ExecutionId == parentExecutionId. + await AuditDataSeeder.InsertAuditEventAsync( + eventId: spawnerEventId, + occurredAtUtc: now, + channel: "ApiInbound", + kind: "InboundRequest", + status: "Delivered", + target: targetPrefix + "spawner", + executionId: parentExecutionId, + httpStatus: 200, + durationMs: 7); + + // The child (spawned) row — ParentExecutionId points at the spawner. + await AuditDataSeeder.InsertAuditEventAsync( + eventId: childEventId, + occurredAtUtc: now, + channel: "ApiOutbound", + kind: "ApiCall", + status: "Delivered", + target: targetPrefix + "child", + executionId: Guid.NewGuid(), + parentExecutionId: parentExecutionId, + httpStatus: 200, + durationMs: 13); + + var page = await _fixture.NewAuthenticatedPageAsync(); + + // Land on the child row via its ParentExecutionId filter, open the drawer. + await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log?parentExecutionId={parentExecutionId}"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + var childRow = page.Locator($"[data-test='grid-row-{childEventId}']"); + await Assertions.Expect(childRow).ToBeVisibleAsync(); + await childRow.ClickAsync(); + + // The "View parent execution" action drills in to the spawner. + var viewParent = page.Locator("[data-test='view-parent-execution']"); + await Assertions.Expect(viewParent).ToBeVisibleAsync(); + await viewParent.ClickAsync(); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + // The drill-in lands on ?executionId={parentExecutionId} and auto-loads + // the spawner's own row. + Assert.Contains($"executionId={parentExecutionId}", page.Url); + var spawnerRow = page.Locator($"[data-test='grid-row-{spawnerEventId}']"); + await Assertions.Expect(spawnerRow).ToBeVisibleAsync(); + } + finally + { + await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix); + } + } + [Fact] public async Task NotificationsPage_RendersAuditDrillInLinkPattern() { diff --git a/tests/ScadaLink.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs b/tests/ScadaLink.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs index dcccf89..4f29c03 100644 --- a/tests/ScadaLink.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs @@ -139,6 +139,7 @@ public class AuditExportEndpointsTests { var correlationId = Guid.NewGuid().ToString(); var executionId = Guid.NewGuid().ToString(); + var parentExecutionId = Guid.NewGuid().ToString(); var url = "/api/centralui/audit/export?" + "channel=ApiOutbound&" + @@ -149,6 +150,7 @@ public class AuditExportEndpointsTests "actor=apikey-1&" + $"correlationId={correlationId}&" + $"executionId={executionId}&" + + $"parentExecutionId={parentExecutionId}&" + "from=2026-05-20T00:00:00Z&" + "to=2026-05-20T23:59:59Z"; @@ -170,6 +172,7 @@ public class AuditExportEndpointsTests f.Actor == "apikey-1" && f.CorrelationId == Guid.Parse(correlationId) && f.ExecutionId == Guid.Parse(executionId) && + f.ParentExecutionId == Guid.Parse(parentExecutionId) && 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(), @@ -199,6 +202,7 @@ public class AuditExportEndpointsTests f.Actor == null && f.CorrelationId == null && f.ExecutionId == null && + f.ParentExecutionId == null && f.FromUtc == null && f.ToUtc == null), Arg.Any(), @@ -245,6 +249,25 @@ public class AuditExportEndpointsTests } } + [Fact] + public async Task ExportEndpoint_UnparseableParentExecutionId_SilentlyDropped() + { + // Lax-parse contract: an unparseable parentExecutionId is dropped (no 400) + // — mirrors the executionId / correlationId parse. + var (client, repo, host) = await BuildHostAsync(); + using (host) + { + var response = await client.GetAsync("/api/centralui/audit/export?parentExecutionId=not-a-guid"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + _ = await response.Content.ReadAsStringAsync(); + + await repo.Received().QueryAsync( + Arg.Is(f => f.ParentExecutionId == null), + Arg.Any(), + Arg.Any()); + } + } + /// /// Test-only authentication handler that signs every request in as an Admin. /// Admin is in AuditExportRoles, so the endpoint's AuditExport policy diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs index b82d944..15bcf62 100644 --- a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs @@ -41,6 +41,7 @@ public class AuditDrilldownDrawerTests : BunitContext string? extra = null, Guid? correlationId = null, Guid? executionId = null, + Guid? parentExecutionId = null, string? errorMessage = null, string? errorDetail = null, string? target = "demo-target") @@ -53,6 +54,7 @@ public class AuditDrilldownDrawerTests : BunitContext Kind = kind, CorrelationId = correlationId, ExecutionId = executionId, + ParentExecutionId = parentExecutionId, SourceSiteId = "plant-a", SourceInstanceId = "boiler-3", SourceScript = "OnAlarm.csx", @@ -258,6 +260,49 @@ public class AuditDrilldownDrawerTests : BunitContext Assert.Contains($"/audit/log?executionId={exec}", nav.Uri); } + [Fact] + public void Drawer_NullParentExecutionId_HidesViewParentExecutionButton() + { + var ev = MakeEvent(parentExecutionId: null); + + var cut = Render(p => p + .Add(c => c.Event, ev) + .Add(c => c.IsOpen, true)); + + Assert.DoesNotContain("data-test=\"view-parent-execution\"", cut.Markup); + } + + [Fact] + public void Drawer_NonNullParentExecutionId_ShowsViewParentExecutionButton() + { + var ev = MakeEvent(parentExecutionId: Guid.Parse("bbbbbbbb-1111-2222-3333-444444444444")); + + var cut = Render(p => p + .Add(c => c.Event, ev) + .Add(c => c.IsOpen, true)); + + Assert.Contains("data-test=\"view-parent-execution\"", cut.Markup); + } + + [Fact] + public void ViewParentExecution_Navigates_WithExecutionIdQueryString() + { + // A routed (child) row drills in to its spawner: the "View parent + // execution" action navigates to /audit/log?executionId={ParentExecutionId} + // so the user sees the spawner execution's rows. + var parent = Guid.Parse("eeeeeeee-dddd-cccc-bbbb-aaaaaaaaaaaa"); + var ev = MakeEvent(parentExecutionId: parent); + + var cut = Render(p => p + .Add(c => c.Event, ev) + .Add(c => c.IsOpen, true)); + + cut.Find("[data-test=\"view-parent-execution\"]").Click(); + + var nav = (BunitNavigationManager)Services.GetRequiredService(); + Assert.Contains($"/audit/log?executionId={parent}", nav.Uri); + } + [Fact] public async Task CopyAsCurl_InvokesClipboard_WithCurlString() { diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs index 3c3b202..d34fa3d 100644 --- a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditFilterBarTests.cs @@ -62,6 +62,7 @@ public class AuditFilterBarTests : BunitContext "data-test=\"filter-target\"", "data-test=\"filter-actor\"", "data-test=\"filter-execution-id\"", + "data-test=\"filter-parent-execution-id\"", "data-test=\"filter-errors-only\"", }; foreach (var marker in markers) @@ -215,6 +216,42 @@ public class AuditFilterBarTests : BunitContext Assert.Null(captured!.ExecutionId); } + [Fact] + public void Apply_WithPastedParentExecutionId_MapsThroughToFilter() + { + // The operator pastes a Guid into the Parent execution ID box; Apply must + // map it straight onto AuditLogQueryFilter.ParentExecutionId. + var parentExecutionId = Guid.Parse("11112222-3333-4444-5555-666677778888"); + AuditLogQueryFilter? captured = null; + var cut = Render(p => p + .Add(c => c.OnFilterChanged, EventCallback.Factory.Create(this, f => captured = f))); + + cut.Find("[data-test=\"filter-parent-execution-id\"] input").Change(parentExecutionId.ToString()); + cut.Find("[data-test=\"filter-apply\"]").Click(); + + Assert.NotNull(captured); + Assert.Equal(parentExecutionId, captured!.ParentExecutionId); + } + + [Fact] + public void Apply_WithBlankOrUnparseableParentExecutionId_LeavesFilterParentExecutionIdNull() + { + // Lax parsing: a blank box yields no constraint; garbage text likewise. + AuditLogQueryFilter? captured = null; + var cut = Render(p => p + .Add(c => c.OnFilterChanged, EventCallback.Factory.Create(this, f => captured = f))); + + // Blank — never typed into. + cut.Find("[data-test=\"filter-apply\"]").Click(); + Assert.NotNull(captured); + Assert.Null(captured!.ParentExecutionId); + + // Unparseable paste — still dropped, no error. + cut.Find("[data-test=\"filter-parent-execution-id\"] input").Change("not-a-guid"); + cut.Find("[data-test=\"filter-apply\"]").Click(); + Assert.Null(captured!.ParentExecutionId); + } + [Fact] public void TimeRange_LastHour_PopulatesFromUtc_ApproxOneHourAgo() { diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs index 4ba39d5..a0549b2 100644 --- a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs @@ -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", Guid? executionId = null) + private static AuditEvent MakeEvent(DateTime occurredAtUtc, AuditStatus status, AuditChannel channel = AuditChannel.ApiOutbound, AuditKind kind = AuditKind.ApiCall, string? site = "plant-a", Guid? executionId = null, Guid? parentExecutionId = null) => new() { EventId = Guid.NewGuid(), @@ -34,6 +34,7 @@ public class AuditResultsGridTests : BunitContext Target = "demo-target", Actor = "tester", ExecutionId = executionId, + ParentExecutionId = parentExecutionId, DurationMs = 42, HttpStatus = status == AuditStatus.Delivered ? 200 : 500, ErrorMessage = status == AuditStatus.Failed ? "boom — unreachable" : null, @@ -165,6 +166,49 @@ public class AuditResultsGridTests : BunitContext Assert.Empty(cut.FindAll($"[data-test=\"execution-id-{row.EventId}\"]")); } + [Fact] + public void Render_IncludesParentExecutionIdColumn() + { + StubPage(new List + { + MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered), + }); + + var cut = Render(p => p.Add(c => c.Filter, new AuditLogQueryFilter())); + + // The ParentExecutionId column header is present alongside the spec columns. + Assert.Contains("data-test=\"col-header-ParentExecutionId\"", cut.Markup); + } + + [Fact] + public void ParentExecutionId_NonNullRow_RendersShortMonospaceValue() + { + var parentExecutionId = Guid.Parse("fedcba98-2222-3333-4444-555555555555"); + var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, parentExecutionId: parentExecutionId); + StubPage(new[] { row }); + + var cut = Render(p => p.Add(c => c.Filter, new AuditLogQueryFilter())); + + var cell = cut.Find($"[data-test=\"parent-execution-id-{row.EventId}\"]"); + // Short form: first 8 hex digits of the "N" form — mirrors ExecutionId. + Assert.Equal("fedcba98", cell.TextContent.Trim()); + // Monospace presentation; full value retained in the title attribute. + Assert.Contains("font-monospace", cell.GetAttribute("class") ?? string.Empty); + Assert.Equal(parentExecutionId.ToString(), cell.GetAttribute("title")); + } + + [Fact] + public void ParentExecutionId_NullRow_RendersBlankPlaceholder_NoParentExecutionIdCell() + { + var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, parentExecutionId: null); + StubPage(new[] { row }); + + var cut = Render(p => p.Add(c => c.Filter, new AuditLogQueryFilter())); + + // A null ParentExecutionId renders the em-dash placeholder, not a value cell. + Assert.Empty(cut.FindAll($"[data-test=\"parent-execution-id-{row.EventId}\"]")); + } + [Fact] public void Status_FailedRow_HasErrorBadgeClass() { @@ -193,7 +237,8 @@ public class AuditResultsGridTests : BunitContext private static readonly string[] DefaultOrder = { "OccurredAtUtc", "Site", "Channel", "Kind", "Status", - "Target", "Actor", "DurationMs", "HttpStatus", "ErrorMessage", + "Target", "Actor", "ExecutionId", "ParentExecutionId", + "DurationMs", "HttpStatus", "ErrorMessage", }; private static int HeaderIndex(string markup, string key) diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageExportUrlTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageExportUrlTests.cs index 02892a7..400f8ee 100644 --- a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageExportUrlTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageExportUrlTests.cs @@ -89,6 +89,20 @@ public class AuditLogPageExportUrlTests Assert.Equal(exec.ToString(), query["executionId"]); } + [Fact] + public void BuildExportUrl_ParentExecutionIdSet_EmitsParentExecutionIdParam() + { + var parent = Guid.Parse("34343434-5656-7878-9090-121212121212"); + var filter = new AuditLogQueryFilter(ParentExecutionId: parent); + + 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(parent.ToString(), query["parentExecutionId"]); + } + [Fact] public void BuildExportUrl_MultiValueDimensions_EmitRepeatedParams() { diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs index 55062dd..30acd90 100644 --- a/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs @@ -214,6 +214,45 @@ public class AuditLogPageScaffoldTests : BunitContext Arg.Any()); } + [Fact] + public void NavigateWithParentExecutionIdParam_AppliesFilter_AndAutoLoads() + { + // The "View parent execution" drill-in (and operator-crafted URLs) land on + // /audit/log?parentExecutionId={id}. The page parses the Guid, builds an + // AuditLogQueryFilter with ParentExecutionId set, and auto-loads the grid. + var parentExecutionId = Guid.Parse("aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb"); + _queryService = Substitute.For(); + _queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(new List())); + + var cut = RenderAuditLogPageWithQuery($"parentExecutionId={parentExecutionId}", "Admin"); + + cut.WaitForAssertion(() => + { + _queryService.Received().QueryAsync( + Arg.Is(f => f.ParentExecutionId == parentExecutionId), + Arg.Any(), + Arg.Any()); + }); + } + + [Fact] + public void NavigateWithUnparseableParentExecutionIdParam_IsSilentlyDropped_NoAutoLoad() + { + _queryService = Substitute.For(); + + var cut = RenderAuditLogPageWithQuery("parentExecutionId=not-a-guid", "Admin"); + + // An unparseable parentExecutionId leaves ParentExecutionId 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(), + Arg.Any(), + Arg.Any()); + } + [Fact] public void NavigateWithTargetParam_AppliesTargetFilter() { From 34a43566255505a8c81ee3096a571c1eb721c9ca Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 18:49:13 -0400 Subject: [PATCH 14/19] feat(centralui): execution-chain tree view on the Audit Log page --- .../Audit/AuditDrilldownDrawer.razor | 8 + .../Audit/AuditDrilldownDrawer.razor.cs | 16 ++ .../Components/Audit/ExecutionTree.razor | 123 +++++++++ .../Components/Audit/ExecutionTree.razor.cs | 241 ++++++++++++++++++ .../Components/Audit/ExecutionTree.razor.css | 137 ++++++++++ .../Pages/Audit/ExecutionTreePage.razor | 63 +++++ .../Pages/Audit/ExecutionTreePage.razor.cs | 93 +++++++ .../Services/AuditLogQueryService.cs | 19 ++ .../Services/IAuditLogQueryService.cs | 19 ++ .../Audit/AuditLogPageTests.cs | 82 ++++++ .../Audit/AuditDrilldownDrawerTests.cs | 42 +++ .../Components/Audit/ExecutionTreeTests.cs | 197 ++++++++++++++ .../Pages/ExecutionTreePageTests.cs | 124 +++++++++ .../Services/AuditLogQueryServiceTests.cs | 60 +++++ 14 files changed, 1224 insertions(+) create mode 100644 src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor create mode 100644 src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.cs create mode 100644 src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.css create mode 100644 src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor create mode 100644 src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor.cs create mode 100644 tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionTreeTests.cs create mode 100644 tests/ScadaLink.CentralUI.Tests/Pages/ExecutionTreePageTests.cs diff --git a/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor b/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor index 1c3ea16..15cac07 100644 --- a/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor +++ b/src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor @@ -173,6 +173,14 @@ View parent execution } + @if (Event.ExecutionId is not null) + { + + } + } + else + { + + } + +
+
+ + @ShortId(node.ExecutionId) + + @if (isCurrent) + { + Arrived from + } + @if (isStub) + { + No audited actions + } + else + { + + @node.RowCount audit @(node.RowCount == 1 ? "row" : "rows") + + } +
+ + @if (isStub) + { +
+ Execution with no audited actions — referenced as a parent, but it + emitted no audit rows of its own (or its rows have been purged). +
+ } + else + { +
+ + Source + @(node.SourceSiteId ?? "—")@(node.SourceInstanceId is null ? "" : " / " + node.SourceInstanceId) + + @if (node.Channels.Count > 0) + { + + Channels + @string.Join(", ", node.Channels) + + } + @if (node.Statuses.Count > 0) + { + + Statuses + @string.Join(", ", node.Statuses) + + } + + Time span + @FormatSpan(node.FirstOccurredAtUtc, node.LastOccurredAtUtc) + +
+ } +
+
+ + @if (subtree.Children.Count > 0 && IsExpanded(node.ExecutionId)) + { + @* Recurse: each child subtree is already assembled, so the + nested instance renders directly from PreBuiltRoots and skips + the flat-list assembly entirely. *@ + + } + + } + diff --git a/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.cs b/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.cs new file mode 100644 index 0000000..a5a5aef --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.cs @@ -0,0 +1,241 @@ +using System.Globalization; +using Microsoft.AspNetCore.Components; +using ScadaLink.Commons.Types.Audit; + +namespace ScadaLink.CentralUI.Components.Audit; + +/// +/// Recursive Blazor tree component for the execution-chain view (Audit Log +/// ParentExecutionId feature, Task 10). +/// +/// +/// Flat list → tree. The repository / query service returns the chain as +/// a FLAT list (one per distinct execution). The +/// root instance ( == 0) assembles it once in +/// : it groups by , +/// links each node to its parent via , +/// and identifies the roots (nodes whose parent is null or not present in the +/// list — a purged/ghost parent). Nested instances skip assembly: the parent +/// hands each child subtree down pre-built via . +/// +/// +/// +/// Cycle safety. The ParentExecutionId graph is acyclic by +/// construction, but the UI must not infinite-loop on corrupt data. Assembly +/// tracks visited values while +/// walking children, so a node is attached to the tree at most once — a cycle +/// (A→B, B→A) is broken at the first revisit and every execution still renders +/// exactly once. +/// +/// +/// +/// Presentation. Each node shows the short execution id (a link to +/// /audit/log?executionId={id}), row count, channels/statuses, source +/// site/instance, and time span. A stub node ( +/// == 0) is marked "No audited actions". The node the user arrived from +/// () is highlighted. Nodes with children +/// are expandable; all nodes start expanded so the whole chain is visible. +/// +/// +public partial class ExecutionTree +{ + /// + /// One assembled subtree: a node plus its already-linked child subtrees. + /// Recursive — children are themselves values. + /// + /// The execution this subtree is rooted at. + /// Child subtrees, ordered by first-occurrence time. + public sealed record Subtree(ExecutionTreeNode Node, IReadOnlyList Children); + + /// + /// The flat node list to assemble into a tree. Supplied on the ROOT + /// instance only ( == 0); nested instances receive + /// instead. + /// + [Parameter] public IReadOnlyList? Nodes { get; set; } + + /// + /// Pre-assembled child subtrees, threaded down from a parent + /// so nested instances render without + /// re-running the flat-list assembly. Null / unused on the root instance. + /// + [Parameter] public IReadOnlyList? PreBuiltRoots { get; set; } + + /// + /// The execution the user drilled in from — its node is visually + /// highlighted so the user keeps their bearings within the chain. + /// + [Parameter] public Guid ArrivedFromExecutionId { get; set; } + + /// + /// Nesting depth. 0 on the root instance (which owns flat-list assembly); + /// each recursive child increments it. Used purely to pick the assembly + /// path and to tag the root <ul> for styling. + /// + [Parameter] public int Depth { get; set; } + + // The subtrees this instance renders: assembled from Nodes on the root, + // or taken straight from PreBuiltRoots on a nested instance. + private IReadOnlyList _rootsToRender = Array.Empty(); + + // Per-execution expand/collapse state. Absent => expanded (the default): + // the whole chain is shown on arrival so the user sees the full picture. + private readonly HashSet _collapsed = new(); + + protected override void OnParametersSet() + { + // Nested instance: the parent already assembled our subtrees. + if (Depth > 0) + { + _rootsToRender = PreBuiltRoots ?? Array.Empty(); + return; + } + + // Root instance: assemble the flat list into a tree. + _rootsToRender = BuildForest(Nodes ?? Array.Empty()); + } + + /// + /// Assembles the flat list into a forest of + /// values. There is normally exactly one root (the + /// chain's topmost ancestor); the method returns a list to stay total if + /// the input ever contains disjoint fragments. + /// + private static IReadOnlyList BuildForest(IReadOnlyList nodes) + { + if (nodes.Count == 0) + { + return Array.Empty(); + } + + // De-dupe defensively: the repository emits one node per execution, but + // a corrupt feed could repeat an id. First write wins. + var byId = new Dictionary(); + foreach (var node in nodes) + { + byId.TryAdd(node.ExecutionId, node); + } + + // Children grouped by parent id. A node whose parent is null or absent + // from the list (a purged/ghost parent) is a root. + var childrenByParent = new Dictionary>(); + var roots = new List(); + foreach (var node in byId.Values) + { + if (node.ParentExecutionId is { } parentId && byId.ContainsKey(parentId)) + { + if (!childrenByParent.TryGetValue(parentId, out var bucket)) + { + bucket = new List(); + childrenByParent[parentId] = bucket; + } + bucket.Add(node); + } + else + { + roots.Add(node); + } + } + + // Cycle guard: if the input is fully cyclic (A→B, B→A) every node has a + // present parent, so `roots` is empty even though there is data to + // show. Fall back to treating the lowest-ordered id as the root so the + // chain still renders — the visited-set below then breaks the cycle. + if (roots.Count == 0) + { + roots.Add(byId.Values.OrderBy(n => n.ExecutionId).First()); + } + + var visited = new HashSet(); + return roots + .OrderBy(SortKey) + .Select(root => BuildSubtree(root, childrenByParent, visited)) + .ToList(); + } + + /// + /// Recursively builds one , tracking + /// so a cyclic flat list cannot drive unbounded + /// recursion — a node already attached is never descended into again. + /// + private static Subtree BuildSubtree( + ExecutionTreeNode node, + IReadOnlyDictionary> childrenByParent, + HashSet visited) + { + visited.Add(node.ExecutionId); + + var children = new List(); + if (childrenByParent.TryGetValue(node.ExecutionId, out var directChildren)) + { + foreach (var child in directChildren.OrderBy(SortKey)) + { + // Cycle / DAG guard: skip any execution already placed in the + // tree so each renders exactly once and recursion terminates. + if (visited.Contains(child.ExecutionId)) + { + continue; + } + children.Add(BuildSubtree(child, childrenByParent, visited)); + } + } + + return new Subtree(node, children); + } + + // Stable child ordering: earliest activity first; stub nodes (null + // timestamp) sort last; ExecutionId breaks ties so rendering is + // deterministic across requests. + private static (DateTime, Guid) SortKey(ExecutionTreeNode node) + => (node.FirstOccurredAtUtc ?? DateTime.MaxValue, node.ExecutionId); + + private bool IsExpanded(Guid executionId) => !_collapsed.Contains(executionId); + + private void ToggleExpand(Guid executionId) + { + if (!_collapsed.Remove(executionId)) + { + _collapsed.Add(executionId); + } + } + + /// Audit Log deep link filtered to one execution's rows. + private static string AuditLogUrl(Guid executionId) + => $"/audit/log?executionId={executionId}"; + + /// First 8 hex digits — the short-id presentation used across the Audit UI. + private static string ShortId(Guid value) + { + var n = value.ToString("N"); + return n.Length >= 8 ? n[..8] : n; + } + + /// + /// Renders the [first, last] occurrence span. Both null on a stub node + /// (handled by the caller); a single-row execution shows one timestamp. + /// + private static string FormatSpan(DateTime? firstUtc, DateTime? lastUtc) + { + if (firstUtc is null && lastUtc is null) + { + return "—"; + } + + var first = firstUtc ?? lastUtc!.Value; + var last = lastUtc ?? firstUtc!.Value; + var firstText = Iso(first); + if (first == last) + { + return firstText; + } + return $"{firstText} → {Iso(last)}"; + } + + private static string Iso(DateTime utc) + { + var kind = utc.Kind == DateTimeKind.Unspecified + ? DateTime.SpecifyKind(utc, DateTimeKind.Utc) + : utc; + return kind.ToString("yyyy-MM-dd HH:mm:ss'Z'", CultureInfo.InvariantCulture); + } +} diff --git a/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.css b/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.css new file mode 100644 index 0000000..8f483a7 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.css @@ -0,0 +1,137 @@ +/* Execution-chain tree (Audit Log ParentExecutionId feature, Task 10). + Clean, corporate, internal-tool aesthetic — consistent with the Audit Log + grid / drilldown drawer. Bootstrap CSS variables drive every colour so the + tree tracks the active theme. No component framework, no JS for layout. */ + +.execution-tree { + list-style: none; + margin: 0; + padding: 0; +} + +/* Nested lists indent and carry a vertical guide rule that ties children to + their parent — the classic file-tree connector, kept subtle. */ +.execution-tree--root { + padding-left: 0; +} + +.execution-tree .execution-tree { + margin-left: 0.75rem; + padding-left: 1rem; + border-left: 1px solid var(--bs-border-color); +} + +.execution-tree-item { + position: relative; +} + +/* The node card: a flex row of [toggle][body]. */ +.execution-tree-node { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.5rem 0.625rem; + margin: 0.25rem 0; + border: 1px solid var(--bs-border-color); + border-radius: 0.375rem; + background-color: var(--bs-body-bg); +} + +/* The execution the user drilled in from — a left accent rule + tinted + background so it stands out without shouting. */ +.execution-tree-node--current { + border-color: var(--bs-primary-border-subtle); + background-color: var(--bs-primary-bg-subtle); + box-shadow: inset 3px 0 0 0 var(--bs-primary); +} + +/* Stub node — an execution with no audited actions. Muted + dashed border so + it reads as a placeholder rather than a real audited execution. */ +.execution-tree-node--stub { + border-style: dashed; + background-color: var(--bs-tertiary-bg); +} + +/* Expand / collapse control. A small square that mirrors the table-light + header tone used elsewhere on the Audit pages. */ +.execution-tree-toggle { + flex: 0 0 auto; + width: 1.25rem; + height: 1.25rem; + margin-top: 0.0625rem; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: 1px solid var(--bs-border-color); + border-radius: 0.25rem; + background-color: var(--bs-tertiary-bg); + color: var(--bs-secondary-color); + line-height: 1; + cursor: pointer; +} + +.execution-tree-toggle:hover { + background-color: var(--bs-secondary-bg); + color: var(--bs-body-color); +} + +.execution-tree-toggle--leaf { + border-color: transparent; + background-color: transparent; + cursor: default; +} + +.execution-tree-toggle-glyph { + font-size: 0.875rem; + font-weight: 600; +} + +.execution-tree-body { + flex: 1 1 auto; + min-width: 0; +} + +/* Headline row: short id link, tags, row count. */ +.execution-tree-headline { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; +} + +.execution-tree-link { + font-size: 0.875rem; + font-weight: 600; + text-decoration: none; +} + +.execution-tree-link:hover { + text-decoration: underline; +} + +.execution-tree-tag { + font-weight: 500; + font-size: 0.6875rem; +} + +.execution-tree-rowcount { + margin-left: auto; +} + +/* Meta row: source / channels / statuses / time span, pipe-separated visually + via spacing rather than literal separators. */ +.execution-tree-meta { + margin-top: 0.25rem; + display: flex; + flex-wrap: wrap; + gap: 0.25rem 1rem; + color: var(--bs-body-color); +} + +.execution-tree-meta-item .text-muted { + margin-right: 0.25rem; + text-transform: uppercase; + font-size: 0.6875rem; + letter-spacing: 0.02em; +} diff --git a/src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor b/src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor new file mode 100644 index 0000000..1883faa --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor @@ -0,0 +1,63 @@ +@page "/audit/execution-tree" +@attribute [Authorize(Policy = AuthorizationPolicies.OperationalAudit)] +@using ScadaLink.CentralUI.Components.Audit +@using ScadaLink.CentralUI.Services +@using ScadaLink.Commons.Types.Audit +@using ScadaLink.Security +@inject IAuditLogQueryService AuditLogQueryService + +Execution Chain + +@* Execution-chain tree view (Audit Log ParentExecutionId feature, Task 10). + A drill-in target reached from the Audit Log drawer's "View execution chain" + action: /audit/execution-tree?executionId={guid}. The page parses the id, + asks the query service for the whole chain (flat ExecutionTreeNode list), and + hands it to the recursive ExecutionTree component. There is deliberately NO + nav-menu entry — this page is only meaningful in the context of a specific + execution, so it is reachable only via drill-in (the Audit nav group keeps + just the Audit Log + Configuration Audit Log pages). *@ + +
+

Execution Chain

+

+ The full chain of script / inbound-request executions linked by + ParentExecutionId, rooted at the + topmost ancestor. Select an execution to open the Audit Log filtered to + its rows. +

+ + @if (_executionId is null) + { + @* No (or unparseable) ?executionId= — render guidance rather than an + empty tree. Mirrors the Audit Log page's silently-drop contract. *@ +
+ No execution selected. Open this view from an audit row's + View execution chain action. +
+ } + else if (_loading) + { +
Loading execution chain…
+ } + else if (_error is not null) + { +
@_error
+ } + else if (_nodes is { Count: > 0 }) + { + + + } + else + { +
+ No execution chain found for this id. +
+ } +
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor.cs b/src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor.cs new file mode 100644 index 0000000..84ac269 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/ExecutionTreePage.razor.cs @@ -0,0 +1,93 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.WebUtilities; +using ScadaLink.Commons.Types.Audit; + +namespace ScadaLink.CentralUI.Components.Pages.Audit; + +/// +/// Code-behind for the execution-chain tree page (Audit Log ParentExecutionId +/// feature, Task 10). Route /audit/execution-tree, reached via the Audit +/// Log drilldown drawer's "View execution chain" action with +/// ?executionId={guid}. +/// +/// +/// On initialization the page parses ?executionId= (lax-parsed, matching +/// the Audit Log page's drill-in contract — an absent or unparseable value +/// leaves the page in a guidance state and issues NO service call), then asks +/// +/// for the whole chain. The flat list is handed +/// to the recursive ExecutionTree component, which assembles + renders +/// the tree. +/// +/// +/// +/// The data path mirrors the Audit Log results grid: the page talks ONLY to the +/// CentralUI IAuditLogQueryService facade, never IAuditLogRepository +/// directly, so the page can be unit-tested with a substituted service. +/// +/// +public partial class ExecutionTreePage +{ + [Inject] private NavigationManager Navigation { get; set; } = null!; + + // The parsed ?executionId= value, or null when absent / unparseable. + private Guid? _executionId; + + // The flat chain returned by the query service; null until the load + // completes (or when no id was supplied). + private IReadOnlyList? _nodes; + + private bool _loading; + private string? _error; + + protected override async Task OnInitializedAsync() + { + _executionId = ParseExecutionId(); + if (_executionId is null) + { + // No id — render guidance, do not touch the service. + return; + } + + await LoadChainAsync(_executionId.Value); + } + + /// + /// Lax-parses ?executionId=. Returns null when the param is absent or + /// is not a valid — the page then shows guidance instead + /// of an error, consistent with the Audit Log page's drill-in handling. + /// + private Guid? ParseExecutionId() + { + var uri = Navigation.ToAbsoluteUri(Navigation.Uri); + var query = QueryHelpers.ParseQuery(uri.Query); + if (query.TryGetValue("executionId", out var values) + && Guid.TryParse(values.ToString(), out var parsed)) + { + return parsed; + } + return null; + } + + private async Task LoadChainAsync(Guid executionId) + { + _loading = true; + _error = null; + try + { + _nodes = await AuditLogQueryService.GetExecutionTreeAsync(executionId); + } + catch (Exception ex) + { + // A transient DB outage degrades this page to an error banner + // rather than killing the circuit — the same defensive posture the + // Audit Log grid takes around its query. + _error = $"Could not load the execution chain: {ex.Message}"; + _nodes = null; + } + finally + { + _loading = false; + } + } +} diff --git a/src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs b/src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs index f4bd2f4..346a8a9 100644 --- a/src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs +++ b/src/ScadaLink.CentralUI/Services/AuditLogQueryService.cs @@ -132,4 +132,23 @@ public sealed class AuditLogQueryService : IAuditLogQueryService return repoSnapshot with { BacklogTotal = backlog }; } + + /// + public async Task> GetExecutionTreeAsync( + Guid executionId, + CancellationToken ct = default) + { + // Test-seam ctor: use the injected repository directly. + if (_injectedRepository is not null) + { + return await _injectedRepository.GetExecutionTreeAsync(executionId, ct); + } + + // Production: a fresh scope (and thus a fresh DbContext) per call — the + // same context-isolation contract QueryAsync upholds, so the tree + // page's auto-load never shares the circuit-scoped context. + await using var scope = _scopeFactory!.CreateAsyncScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + return await repository.GetExecutionTreeAsync(executionId, ct); + } } diff --git a/src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs b/src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs index 08b85d8..e802ec5 100644 --- a/src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs +++ b/src/ScadaLink.CentralUI/Services/IAuditLogQueryService.cs @@ -50,4 +50,23 @@ public interface IAuditLogQueryService /// dashboard. /// Task GetKpiSnapshotAsync(CancellationToken ct = default); + + /// + /// Audit Log ParentExecutionId feature (Task 10) — returns the full + /// execution chain containing as a flat list + /// of , delegating to + /// . + /// The execution-chain tree view (/audit/execution-tree) assembles the + /// returned flat list into a tree by joining + /// to a parent node's + /// . + /// + /// + /// A pure pass-through, mirroring — the production + /// implementation opens its own DI scope per call so the tree page's + /// auto-load never contends with the circuit-scoped ScadaLinkDbContext. + /// + Task> GetExecutionTreeAsync( + Guid executionId, + CancellationToken ct = default); } diff --git a/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs index 4fefe5d..256c7d1 100644 --- a/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs +++ b/tests/ScadaLink.CentralUI.PlaywrightTests/Audit/AuditLogPageTests.cs @@ -429,6 +429,88 @@ public class AuditLogPageTests } } + [Fact] + public async Task DrillInToExecutionChain_RendersTree_AndNodeClickFiltersGrid() + { + // Audit Log ParentExecutionId feature, Task 10: the drawer's "View + // execution chain" action opens /audit/execution-tree?executionId={id}. + // We seed a spawner row + a child row, open the child's drawer, click + // "View execution chain", assert the tree renders BOTH executions, then + // click the spawner node and assert the Audit Log grid filters to it. + 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-chain-tree/{runId}/"; + var parentExecutionId = Guid.NewGuid(); + var childExecutionId = Guid.NewGuid(); + var spawnerEventId = Guid.NewGuid(); + var childEventId = Guid.NewGuid(); + var now = DateTime.UtcNow; + + try + { + // Spawner execution's own row. + await AuditDataSeeder.InsertAuditEventAsync( + eventId: spawnerEventId, + occurredAtUtc: now, + channel: "ApiInbound", + kind: "InboundRequest", + status: "Delivered", + target: targetPrefix + "spawner", + executionId: parentExecutionId, + httpStatus: 200, + durationMs: 7); + + // Child (spawned) row — links to the spawner via ParentExecutionId. + await AuditDataSeeder.InsertAuditEventAsync( + eventId: childEventId, + occurredAtUtc: now, + channel: "ApiOutbound", + kind: "ApiCall", + status: "Delivered", + target: targetPrefix + "child", + executionId: childExecutionId, + parentExecutionId: parentExecutionId, + httpStatus: 200, + durationMs: 13); + + var page = await _fixture.NewAuthenticatedPageAsync(); + + // Open the child row's drawer via its ExecutionId filter. + await page.GotoAsync($"{PlaywrightFixture.BaseUrl}/audit/log?executionId={childExecutionId}"); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + var childRow = page.Locator($"[data-test='grid-row-{childEventId}']"); + await Assertions.Expect(childRow).ToBeVisibleAsync(); + await childRow.ClickAsync(); + + // "View execution chain" opens the tree view. + var viewChain = page.Locator("[data-test='view-execution-chain']"); + await Assertions.Expect(viewChain).ToBeVisibleAsync(); + await viewChain.ClickAsync(); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + // The tree page rendered both executions as nodes. + Assert.Contains($"executionId={childExecutionId}", page.Url); + await Assertions.Expect(page.Locator($"[data-test='tree-node-{parentExecutionId}']")).ToBeVisibleAsync(); + await Assertions.Expect(page.Locator($"[data-test='tree-node-{childExecutionId}']")).ToBeVisibleAsync(); + + // Clicking the spawner node's link filters the Audit Log to its rows. + await page.Locator($"[data-test='tree-node-link-{parentExecutionId}']").ClickAsync(); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + Assert.Contains($"executionId={parentExecutionId}", page.Url); + await Assertions.Expect(page.Locator($"[data-test='grid-row-{spawnerEventId}']")).ToBeVisibleAsync(); + } + finally + { + await AuditDataSeeder.DeleteByTargetPrefixAsync(targetPrefix); + } + } + [Fact] public async Task NotificationsPage_RendersAuditDrillInLinkPattern() { diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs index 15bcf62..78608cd 100644 --- a/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/AuditDrilldownDrawerTests.cs @@ -303,6 +303,48 @@ public class AuditDrilldownDrawerTests : BunitContext Assert.Contains($"/audit/log?executionId={parent}", nav.Uri); } + [Fact] + public void Drawer_NullExecutionId_HidesViewExecutionChainButton() + { + var ev = MakeEvent(executionId: null); + + var cut = Render(p => p + .Add(c => c.Event, ev) + .Add(c => c.IsOpen, true)); + + Assert.DoesNotContain("data-test=\"view-execution-chain\"", cut.Markup); + } + + [Fact] + public void Drawer_NonNullExecutionId_ShowsViewExecutionChainButton() + { + var ev = MakeEvent(executionId: Guid.Parse("aaaaaaaa-9999-8888-7777-666666666666")); + + var cut = Render(p => p + .Add(c => c.Event, ev) + .Add(c => c.IsOpen, true)); + + Assert.Contains("data-test=\"view-execution-chain\"", cut.Markup); + } + + [Fact] + public void ViewExecutionChain_Navigates_ToExecutionTreePage() + { + // The "View execution chain" action opens the tree view rooted at the + // chain containing this row's ExecutionId. + var exec = Guid.Parse("12345678-aaaa-bbbb-cccc-1234567890ab"); + var ev = MakeEvent(executionId: exec); + + var cut = Render(p => p + .Add(c => c.Event, ev) + .Add(c => c.IsOpen, true)); + + cut.Find("[data-test=\"view-execution-chain\"]").Click(); + + var nav = (BunitNavigationManager)Services.GetRequiredService(); + Assert.Contains($"/audit/execution-tree?executionId={exec}", nav.Uri); + } + [Fact] public async Task CopyAsCurl_InvokesClipboard_WithCurlString() { diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionTreeTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionTreeTests.cs new file mode 100644 index 0000000..5da931e --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionTreeTests.cs @@ -0,0 +1,197 @@ +using Bunit; +using ScadaLink.CentralUI.Components.Audit; +using ScadaLink.Commons.Types.Audit; + +namespace ScadaLink.CentralUI.Tests.Components.Audit; + +/// +/// bUnit tests for (Audit Log ParentExecutionId +/// feature, Task 10). The component takes the FLAT +/// list the repository returns, assembles it +/// into a tree by joining to a +/// parent node's , and renders it +/// recursively. Tests pin: single-node tree, multi-level assembly, stub-node +/// presentation, the arrived-from highlight, node-click navigation, and +/// cycle-safety (a corrupt flat list must not infinite-loop). +/// +public class ExecutionTreeTests : BunitContext +{ + private static ExecutionTreeNode Node( + Guid executionId, + Guid? parentExecutionId, + int rowCount = 2, + string? site = "plant-a", + string? instance = "boiler-3") + => new( + executionId, + parentExecutionId, + rowCount, + rowCount == 0 ? Array.Empty() : new[] { "ApiOutbound" }, + rowCount == 0 ? Array.Empty() : new[] { "Delivered" }, + rowCount == 0 ? null : site, + rowCount == 0 ? null : instance, + rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), + rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 5, DateTimeKind.Utc)); + + [Fact] + public void SingleNode_RendersOneTreeNode() + { + var id = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var nodes = new List { Node(id, null) }; + + var cut = Render(p => p + .Add(c => c.Nodes, nodes) + .Add(c => c.ArrivedFromExecutionId, id)); + + Assert.Contains($"data-test=\"tree-node-{id}\"", cut.Markup); + } + + [Fact] + public void MultiLevel_AssemblesTree_FromFlatList() + { + // root → child → grandchild — a deliberately shuffled flat list so the + // component must reconstruct parent/child links rather than rely on + // input ordering. + var root = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000000"); + var child = Guid.Parse("bbbbbbbb-0000-0000-0000-000000000000"); + var grandchild = Guid.Parse("cccccccc-0000-0000-0000-000000000000"); + var nodes = new List + { + Node(grandchild, child), + Node(root, null), + Node(child, root), + }; + + var cut = Render(p => p + .Add(c => c.Nodes, nodes) + .Add(c => c.ArrivedFromExecutionId, child)); + + // All three executions render as nodes. + Assert.Contains($"data-test=\"tree-node-{root}\"", cut.Markup); + Assert.Contains($"data-test=\"tree-node-{child}\"", cut.Markup); + Assert.Contains($"data-test=\"tree-node-{grandchild}\"", cut.Markup); + + // The root must appear before the child, and the child before the + // grandchild — recursive depth-first rendering preserves ancestry. + var rootIdx = cut.Markup.IndexOf($"tree-node-{root}", StringComparison.Ordinal); + var childIdx = cut.Markup.IndexOf($"tree-node-{child}", StringComparison.Ordinal); + var grandIdx = cut.Markup.IndexOf($"tree-node-{grandchild}", StringComparison.Ordinal); + Assert.True(rootIdx < childIdx, "root must render before child"); + Assert.True(childIdx < grandIdx, "child must render before grandchild"); + } + + [Fact] + public void StubNode_RendersStubMarker() + { + // A stub parent (RowCount = 0) referenced by a real child must still + // render, visibly marked as "no audited actions". + var stubParent = Guid.Parse("dddddddd-0000-0000-0000-000000000000"); + var child = Guid.Parse("eeeeeeee-0000-0000-0000-000000000000"); + var nodes = new List + { + Node(stubParent, null, rowCount: 0), + Node(child, stubParent), + }; + + var cut = Render(p => p + .Add(c => c.Nodes, nodes) + .Add(c => c.ArrivedFromExecutionId, child)); + + Assert.Contains($"data-test=\"tree-node-{stubParent}\"", cut.Markup); + Assert.Contains($"data-test=\"stub-node-{stubParent}\"", cut.Markup); + Assert.Contains("no audited actions", cut.Markup); + } + + [Fact] + public void ArrivedFromNode_IsVisuallyHighlighted() + { + var root = Guid.Parse("aaaaaaaa-1111-1111-1111-111111111111"); + var child = Guid.Parse("bbbbbbbb-1111-1111-1111-111111111111"); + var nodes = new List + { + Node(root, null), + Node(child, root), + }; + + var cut = Render(p => p + .Add(c => c.Nodes, nodes) + .Add(c => c.ArrivedFromExecutionId, child)); + + // The arrived-from node carries the highlight marker; a non-arrived + // sibling does not. + var arrived = cut.Find($"[data-test=\"tree-node-{child}\"]"); + Assert.Contains("execution-tree-node--current", arrived.GetAttribute("class")); + + var other = cut.Find($"[data-test=\"tree-node-{root}\"]"); + Assert.DoesNotContain("execution-tree-node--current", other.GetAttribute("class") ?? string.Empty); + } + + [Fact] + public void NodeLink_PointsTo_AuditLogFilteredByThatExecution() + { + // Each node's id is a real deep link — clicking it lands on + // the Audit Log filtered to that execution's rows. A genuine anchor + // (rather than an @onclick navigate) keeps the link middle-click / + // open-in-new-tab friendly, matching the rest of the Audit UI. + var root = Guid.Parse("aaaaaaaa-2222-2222-2222-222222222222"); + var child = Guid.Parse("bbbbbbbb-2222-2222-2222-222222222222"); + var nodes = new List + { + Node(root, null), + Node(child, root), + }; + + var cut = Render(p => p + .Add(c => c.Nodes, nodes) + .Add(c => c.ArrivedFromExecutionId, root)); + + var childLink = cut.Find($"[data-test=\"tree-node-link-{child}\"]"); + Assert.Equal($"/audit/log?executionId={child}", childLink.GetAttribute("href")); + + var rootLink = cut.Find($"[data-test=\"tree-node-link-{root}\"]"); + Assert.Equal($"/audit/log?executionId={root}", rootLink.GetAttribute("href")); + } + + [Fact] + public void EmptyNodeList_RendersNothingWithoutThrowing() + { + var cut = Render(p => p + .Add(c => c.Nodes, (IReadOnlyList)Array.Empty()) + .Add(c => c.ArrivedFromExecutionId, Guid.NewGuid())); + + Assert.DoesNotContain("data-test=\"tree-node-", cut.Markup); + } + + [Fact] + public void CyclicFlatList_TerminatesWithoutInfiniteLoop() + { + // Defensive: a corrupt flat list where A→B and B→A must not hang the + // renderer. Each execution is rendered at most once. + var a = Guid.Parse("a0000000-0000-0000-0000-000000000000"); + var b = Guid.Parse("b0000000-0000-0000-0000-000000000000"); + var nodes = new List + { + Node(a, b), + Node(b, a), + }; + + var cut = Render(p => p + .Add(c => c.Nodes, nodes) + .Add(c => c.ArrivedFromExecutionId, a)); + + // Both render exactly once — no runaway recursion. + Assert.Equal(1, CountOccurrences(cut.Markup, $"data-test=\"tree-node-{a}\"")); + Assert.Equal(1, CountOccurrences(cut.Markup, $"data-test=\"tree-node-{b}\"")); + } + + private static int CountOccurrences(string haystack, string needle) + { + int count = 0, idx = 0; + while ((idx = haystack.IndexOf(needle, idx, StringComparison.Ordinal)) >= 0) + { + count++; + idx += needle.Length; + } + return count; + } +} diff --git a/tests/ScadaLink.CentralUI.Tests/Pages/ExecutionTreePageTests.cs b/tests/ScadaLink.CentralUI.Tests/Pages/ExecutionTreePageTests.cs new file mode 100644 index 0000000..c69d10b --- /dev/null +++ b/tests/ScadaLink.CentralUI.Tests/Pages/ExecutionTreePageTests.cs @@ -0,0 +1,124 @@ +using System.Security.Claims; +using Bunit; +using Bunit.TestDoubles; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using ScadaLink.CentralUI.Services; +using ScadaLink.Commons.Types.Audit; +using ScadaLink.Security; +using ExecutionTreePage = ScadaLink.CentralUI.Components.Pages.Audit.ExecutionTreePage; + +namespace ScadaLink.CentralUI.Tests.Pages; + +/// +/// bUnit tests for (Audit Log ParentExecutionId +/// feature, Task 10). The page is reached via the "View execution chain" +/// drill-in at /audit/execution-tree?executionId={guid}. It parses the +/// query-string id, calls , +/// and hands the flat node list to the ExecutionTree component. +/// +public class ExecutionTreePageTests : BunitContext +{ + private IAuditLogQueryService _queryService = Substitute.For(); + + private static ClaimsPrincipal BuildPrincipal(params string[] roles) + { + var claims = new List { new("Username", "tester") }; + claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r))); + return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); + } + + private IRenderedComponent RenderPage(string? query, params string[] roles) + { + var user = BuildPrincipal(roles); + Services.AddSingleton(new TestAuthStateProvider(user)); + Services.AddAuthorizationCore(); + AuthorizationPolicies.AddScadaLinkAuthorization(Services); + Services.AddSingleton(); + Services.AddSingleton(_queryService); + + if (!string.IsNullOrEmpty(query)) + { + var nav = (BunitNavigationManager)Services.GetRequiredService(); + nav.NavigateTo($"/audit/execution-tree?{query}"); + } + + var host = Render(parameters => parameters + .Add(p => p.ChildContent, (RenderFragment)(builder => + { + builder.OpenComponent(0); + builder.CloseComponent(); + }))); + + return host.FindComponent(); + } + + private static ExecutionTreeNode Node(Guid id, Guid? parent, int rowCount = 2) + => new( + id, parent, rowCount, + rowCount == 0 ? Array.Empty() : new[] { "ApiOutbound" }, + rowCount == 0 ? Array.Empty() : new[] { "Delivered" }, + rowCount == 0 ? null : "plant-a", + rowCount == 0 ? null : "boiler-3", + rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), + rowCount == 0 ? null : new DateTime(2026, 5, 20, 12, 0, 5, DateTimeKind.Utc)); + + [Fact] + public void NavigateWithExecutionId_CallsService_AndRendersTree() + { + var root = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var child = Guid.Parse("22222222-2222-2222-2222-222222222222"); + _queryService = Substitute.For(); + _queryService.GetExecutionTreeAsync(child, Arg.Any()) + .Returns(Task.FromResult>(new List + { + Node(root, null), + Node(child, root), + })); + + var cut = RenderPage($"executionId={child}", "Admin"); + + cut.WaitForAssertion(() => + { + _queryService.Received().GetExecutionTreeAsync(child, Arg.Any()); + Assert.Contains($"data-test=\"tree-node-{root}\"", cut.Markup); + Assert.Contains($"data-test=\"tree-node-{child}\"", cut.Markup); + }); + } + + [Fact] + public void NavigateWithoutExecutionId_RendersGuidancePrompt_NoServiceCall() + { + _queryService = Substitute.For(); + + var cut = RenderPage(query: null, "Admin"); + + cut.WaitForAssertion(() => Assert.Contains("Execution Chain", cut.Markup)); + _queryService.DidNotReceive().GetExecutionTreeAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public void NavigateWithUnparseableExecutionId_RendersGuidancePrompt_NoServiceCall() + { + _queryService = Substitute.For(); + + var cut = RenderPage("executionId=not-a-guid", "Admin"); + + cut.WaitForAssertion(() => Assert.Contains("Execution Chain", cut.Markup)); + _queryService.DidNotReceive().GetExecutionTreeAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public void ExecutionTreePage_HasOperationalAuditAuthorizeAttribute() + { + var attributes = typeof(ExecutionTreePage) + .GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true) + .Cast() + .ToList(); + + Assert.Contains(attributes, a => a.Policy == AuthorizationPolicies.OperationalAudit); + } +} diff --git a/tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs b/tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs index 03e0e82..f947dd9 100644 --- a/tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Services/AuditLogQueryServiceTests.cs @@ -222,6 +222,66 @@ public class AuditLogQueryServiceTests Assert.NotSame(resolvedRepos[0], resolvedRepos[1]); } + // ───────────────────────────────────────────────────────────────────────── + // Audit Log ParentExecutionId feature (Task 10): GetExecutionTreeAsync — + // a thin pass-through over IAuditLogRepository.GetExecutionTreeAsync, mirroring + // QueryAsync's scope-per-call contract on the production path. + // ───────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetExecutionTreeAsync_ForwardsExecutionId_ToRepository() + { + var repo = Substitute.For(); + var executionId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + var expected = new List + { + new(executionId, null, 3, + new[] { "ApiOutbound" }, new[] { "Delivered" }, + "plant-a", "boiler-3", + new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), + new DateTime(2026, 5, 20, 12, 0, 5, DateTimeKind.Utc)), + }; + repo.GetExecutionTreeAsync(executionId, Arg.Any()) + .Returns(Task.FromResult>(expected)); + + var sut = new AuditLogQueryService(repo, EmptyAggregator()); + + var result = await sut.GetExecutionTreeAsync(executionId); + + Assert.Same(expected, result); + await repo.Received(1).GetExecutionTreeAsync(executionId, Arg.Any()); + } + + [Fact] + public async Task GetExecutionTreeAsync_OpensFreshScopePerCall_OnProductionCtor() + { + // The production ctor must resolve a fresh repository per call — same + // scope-per-query contract QueryAsync upholds, so the page's auto-load + // never shares the circuit-scoped DbContext. + var resolvedRepos = new List(); + + var services = new ServiceCollection(); + services.AddScoped(_ => + { + var repo = Substitute.For(); + repo.GetExecutionTreeAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult>(Array.Empty())); + resolvedRepos.Add(repo); + return repo; + }); + + await using var provider = services.BuildServiceProvider(); + var sut = new AuditLogQueryService( + provider.GetRequiredService(), + EmptyAggregator()); + + await sut.GetExecutionTreeAsync(Guid.NewGuid()); + await sut.GetExecutionTreeAsync(Guid.NewGuid()); + + Assert.Equal(2, resolvedRepos.Count); + Assert.NotSame(resolvedRepos[0], resolvedRepos[1]); + } + private static SiteHealthState StateWithBacklog(string siteId, int? pending) { SiteAuditBacklogSnapshot? backlog = pending.HasValue From 9b1f78638b52f8e00c0ec16e56c48e6d50961d29 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 18:56:03 -0400 Subject: [PATCH 15/19] refactor(centralui): complete cycle fallback + polish in ExecutionTree --- .../Components/Audit/ExecutionTree.razor.cs | 65 +++++++++++++------ .../Components/Audit/ExecutionTreeTests.cs | 43 ++++++++++++ 2 files changed, 88 insertions(+), 20 deletions(-) diff --git a/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.cs b/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.cs index a5a5aef..c415d48 100644 --- a/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.cs +++ b/src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.cs @@ -44,7 +44,11 @@ public partial class ExecutionTree /// Recursive — children are themselves values. ///
/// The execution this subtree is rooted at. - /// Child subtrees, ordered by first-occurrence time. + /// + /// Child subtrees, ordered by (FirstOccurredAtUtc ?? DateTime.MaxValue, + /// ExecutionId) — earliest first-occurrence time first, stub nodes + /// (null timestamp) last, with ExecutionId breaking ties. + /// public sealed record Subtree(ExecutionTreeNode Node, IReadOnlyList Children); /// @@ -78,6 +82,11 @@ public partial class ExecutionTree // or taken straight from PreBuiltRoots on a nested instance. private IReadOnlyList _rootsToRender = Array.Empty(); + // The Nodes reference the current _rootsToRender was assembled from. Used + // to skip a redundant re-assembly when OnParametersSet fires for an + // unrelated parameter change (the flat list itself is unchanged). + private IReadOnlyList? _assembledFrom; + // Per-execution expand/collapse state. Absent => expanded (the default): // the whole chain is shown on arrival so the user sees the full picture. private readonly HashSet _collapsed = new(); @@ -91,15 +100,25 @@ public partial class ExecutionTree return; } - // Root instance: assemble the flat list into a tree. - _rootsToRender = BuildForest(Nodes ?? Array.Empty()); + // Root instance: assemble the flat list into a tree. Re-assemble only + // when the Nodes reference itself changes — OnParametersSet also fires + // for unrelated parameter changes (e.g. ArrivedFromExecutionId), and + // re-running assembly then would needlessly rebuild an identical tree. + if (!ReferenceEquals(Nodes, _assembledFrom)) + { + _assembledFrom = Nodes; + _rootsToRender = BuildForest(Nodes ?? Array.Empty()); + } } /// /// Assembles the flat list into a forest of /// values. There is normally exactly one root (the /// chain's topmost ancestor); the method returns a list to stay total if - /// the input ever contains disjoint fragments. + /// the input ever contains disjoint fragments. A fully-cyclic feed has no + /// real root, so each remaining cyclic component is seeded with a fallback + /// root after the main pass — every execution in + /// is therefore placed in the forest exactly once. /// private static IReadOnlyList BuildForest(IReadOnlyList nodes) { @@ -137,20 +156,29 @@ public partial class ExecutionTree } } - // Cycle guard: if the input is fully cyclic (A→B, B→A) every node has a - // present parent, so `roots` is empty even though there is data to - // show. Fall back to treating the lowest-ordered id as the root so the - // chain still renders — the visited-set below then breaks the cycle. - if (roots.Count == 0) - { - roots.Add(byId.Values.OrderBy(n => n.ExecutionId).First()); - } - var visited = new HashSet(); - return roots + var forest = roots .OrderBy(SortKey) .Select(root => BuildSubtree(root, childrenByParent, visited)) .ToList(); + + // Cycle guard: if the input is fully cyclic every node has a present + // parent, so a cyclic component contributes no entry to `roots`. Any + // execution still missing from `visited` after the pass above belongs + // to such a component (a corrupt feed may contain several independent + // cycles, e.g. A↔B and C↔D). Seed the lowest-ordered unvisited id of + // each remaining component as an extra root and assemble it, looping + // until every node has been placed — so every execution renders. + while (visited.Count < byId.Count) + { + var fallbackRoot = byId.Values + .Where(n => !visited.Contains(n.ExecutionId)) + .OrderBy(SortKey) + .First(); + forest.Add(BuildSubtree(fallbackRoot, childrenByParent, visited)); + } + + return forest; } /// @@ -231,11 +259,8 @@ public partial class ExecutionTree return $"{firstText} → {Iso(last)}"; } + // Audit timestamps are UTC by system convention, so the value is formatted + // with a literal 'Z' suffix without re-tagging its DateTimeKind. private static string Iso(DateTime utc) - { - var kind = utc.Kind == DateTimeKind.Unspecified - ? DateTime.SpecifyKind(utc, DateTimeKind.Utc) - : utc; - return kind.ToString("yyyy-MM-dd HH:mm:ss'Z'", CultureInfo.InvariantCulture); - } + => utc.ToString("yyyy-MM-dd HH:mm:ss'Z'", CultureInfo.InvariantCulture); } diff --git a/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionTreeTests.cs b/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionTreeTests.cs index 5da931e..43b5825 100644 --- a/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionTreeTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/Components/Audit/ExecutionTreeTests.cs @@ -184,6 +184,49 @@ public class ExecutionTreeTests : BunitContext Assert.Equal(1, CountOccurrences(cut.Markup, $"data-test=\"tree-node-{b}\"")); } + [Fact] + public void ToggleExpand_CollapsesAndReExpandsChildSubtree() + { + // root → child → grandchild. Clicking the root's toggle collapses its + // subtree (the child node disappears); clicking it again re-expands. + var root = Guid.Parse("aaaaaaaa-3333-3333-3333-333333333333"); + var child = Guid.Parse("bbbbbbbb-3333-3333-3333-333333333333"); + var grandchild = Guid.Parse("cccccccc-3333-3333-3333-333333333333"); + var nodes = new List + { + Node(root, null), + Node(child, root), + Node(grandchild, child), + }; + + var cut = Render(p => p + .Add(c => c.Nodes, nodes) + .Add(c => c.ArrivedFromExecutionId, root)); + + // All nodes start expanded — the whole chain is visible on arrival. + Assert.Contains($"data-test=\"tree-node-{child}\"", cut.Markup); + Assert.Contains($"data-test=\"tree-node-{grandchild}\"", cut.Markup); + + var toggle = cut.Find($"[data-test=\"tree-toggle-{root}\"]"); + Assert.Equal("true", toggle.GetAttribute("aria-expanded")); + + // Collapse: the child (and its descendants) must disappear. + toggle.Click(); + Assert.DoesNotContain($"data-test=\"tree-node-{child}\"", cut.Markup); + Assert.DoesNotContain($"data-test=\"tree-node-{grandchild}\"", cut.Markup); + Assert.Equal( + "false", + cut.Find($"[data-test=\"tree-toggle-{root}\"]").GetAttribute("aria-expanded")); + + // Re-expand: the child subtree reappears. + cut.Find($"[data-test=\"tree-toggle-{root}\"]").Click(); + Assert.Contains($"data-test=\"tree-node-{child}\"", cut.Markup); + Assert.Contains($"data-test=\"tree-node-{grandchild}\"", cut.Markup); + Assert.Equal( + "true", + cut.Find($"[data-test=\"tree-toggle-{root}\"]").GetAttribute("aria-expanded")); + } + private static int CountOccurrences(string haystack, string needle) { int count = 0, idx = 0; From 592cbd028ed80659dd88786941dc5863a7a9909a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 18:59:06 -0400 Subject: [PATCH 16/19] feat(audit): ParentExecutionId filter in the CLI and ManagementService --- src/ScadaLink.CLI/Commands/AuditCommands.cs | 3 ++ .../Commands/AuditQueryHelpers.cs | 2 ++ .../AuditEndpoints.cs | 8 +++++ .../Commands/AuditQueryCommandTests.cs | 26 +++++++++++++++ .../AuditEndpointsTests.cs | 32 +++++++++++++++++++ 5 files changed, 71 insertions(+) diff --git a/src/ScadaLink.CLI/Commands/AuditCommands.cs b/src/ScadaLink.CLI/Commands/AuditCommands.cs index 42d4e3a..2951e59 100644 --- a/src/ScadaLink.CLI/Commands/AuditCommands.cs +++ b/src/ScadaLink.CLI/Commands/AuditCommands.cs @@ -60,6 +60,7 @@ public static class AuditCommands var actorOption = new Option("--actor") { Description = "Filter by actor" }; var correlationIdOption = new Option("--correlation-id") { Description = "Filter by correlation ID" }; var executionIdOption = new Option("--execution-id") { Description = "Filter by execution ID" }; + var parentExecutionIdOption = new Option("--parent-execution-id") { Description = "Filter by parent execution ID" }; var errorsOnlyOption = new Option("--errors-only") { Description = "Show only failed events (status=Failed; overrides --status)" }; var pageSizeOption = new Option("--page-size") { Description = "Events per page (1-1000)" }; pageSizeOption.DefaultValueFactory = _ => 100; @@ -76,6 +77,7 @@ public static class AuditCommands cmd.Add(actorOption); cmd.Add(correlationIdOption); cmd.Add(executionIdOption); + cmd.Add(parentExecutionIdOption); cmd.Add(errorsOnlyOption); cmd.Add(pageSizeOption); cmd.Add(allOption); @@ -104,6 +106,7 @@ public static class AuditCommands Actor = result.GetValue(actorOption), CorrelationId = result.GetValue(correlationIdOption), ExecutionId = result.GetValue(executionIdOption), + ParentExecutionId = result.GetValue(parentExecutionIdOption), ErrorsOnly = result.GetValue(errorsOnlyOption), PageSize = result.GetValue(pageSizeOption), }; diff --git a/src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs b/src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs index fda804e..f8640eb 100644 --- a/src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs +++ b/src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs @@ -25,6 +25,7 @@ public sealed class AuditQueryArgs public string? Actor { get; set; } public string? CorrelationId { get; set; } public string? ExecutionId { get; set; } + public string? ParentExecutionId { get; set; } public bool ErrorsOnly { get; set; } public int PageSize { get; set; } = 100; } @@ -127,6 +128,7 @@ public static class AuditQueryHelpers Add("actor", args.Actor); Add("correlationId", args.CorrelationId); Add("executionId", args.ExecutionId); + Add("parentExecutionId", args.ParentExecutionId); Add("pageSize", args.PageSize.ToString(CultureInfo.InvariantCulture)); if (afterOccurredAtUtc.HasValue) diff --git a/src/ScadaLink.ManagementService/AuditEndpoints.cs b/src/ScadaLink.ManagementService/AuditEndpoints.cs index a1d2572..9f15e19 100644 --- a/src/ScadaLink.ManagementService/AuditEndpoints.cs +++ b/src/ScadaLink.ManagementService/AuditEndpoints.cs @@ -402,6 +402,13 @@ public static class AuditEndpoints executionId = parsedExec; } + Guid? parentExecutionId = null; + if (query.TryGetValue("parentExecutionId", out var parentExecValues) + && Guid.TryParse(parentExecValues.ToString(), out var parsedParentExec)) + { + parentExecutionId = parsedParentExec; + } + return new AuditLogQueryFilter( Channels: channels, Kinds: kinds, @@ -411,6 +418,7 @@ public static class AuditEndpoints Actor: TrimToNullable(query, "actor"), CorrelationId: correlationId, ExecutionId: executionId, + ParentExecutionId: parentExecutionId, FromUtc: ParseUtcDate(query, "fromUtc"), ToUtc: ParseUtcDate(query, "toUtc")); } diff --git a/tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs b/tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs index 62c5abb..811683c 100644 --- a/tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs +++ b/tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs @@ -66,6 +66,7 @@ public class AuditQueryCommandTests Actor = "multi-role", CorrelationId = "abc-123", ExecutionId = "def-456", + ParentExecutionId = "ghi-789", ErrorsOnly = false, PageSize = 250, }; @@ -83,6 +84,7 @@ public class AuditQueryCommandTests Assert.Equal("multi-role", parsed["actor"]); Assert.Equal("abc-123", parsed["correlationId"]); Assert.Equal("def-456", parsed["executionId"]); + Assert.Equal("ghi-789", parsed["parentExecutionId"]); 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"]); @@ -159,6 +161,7 @@ public class AuditQueryCommandTests Assert.Null(parsed["fromUtc"]); Assert.Null(parsed["correlationId"]); Assert.Null(parsed["executionId"]); + Assert.Null(parsed["parentExecutionId"]); Assert.Equal("100", parsed["pageSize"]); } @@ -173,6 +176,17 @@ public class AuditQueryCommandTests Assert.Equal("11111111-1111-1111-1111-111111111111", parsed["executionId"]); } + [Fact] + public void BuildQueryString_ParentExecutionId_EmitsParentExecutionIdParameter() + { + // --parent-execution-id is a single-value Guid filter — mirrors --execution-id. + var now = DateTimeOffset.UtcNow; + var args = new AuditQueryArgs { ParentExecutionId = "22222222-2222-2222-2222-222222222222" }; + var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null); + var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?')); + Assert.Equal("22222222-2222-2222-2222-222222222222", parsed["parentExecutionId"]); + } + // ---- HTTP execution / paging ------------------------------------------ private sealed class RecordingHandler : HttpMessageHandler @@ -308,6 +322,18 @@ public class AuditQueryCommandTests Assert.Empty(parse.Errors); } + [Fact] + public void Query_ParentExecutionIdOption_IsAccepted() + { + // --parent-execution-id is a single-value option — mirrors --execution-id. + var root = AuditCommandTestHarness.BuildRoot(); + var parse = root.Parse(new[] + { + "audit", "query", "--parent-execution-id", "22222222-2222-2222-2222-222222222222", + }); + Assert.Empty(parse.Errors); + } + // ---- Enum-name validation (fast-fail) ---------------------------------- [Fact] diff --git a/tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs b/tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs index 7a2813a..190771c 100644 --- a/tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs +++ b/tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs @@ -460,6 +460,38 @@ public class AuditEndpointsTests Assert.Null(filter.ExecutionId); } + [Fact] + public void ParseFilter_ParentExecutionId_ParsesIntoSingleValueGuid() + { + // parentExecutionId is a single-value Guid? filter — mirrors executionId. + var parentExecutionId = Guid.NewGuid(); + var query = new Microsoft.AspNetCore.Http.QueryCollection( + new Dictionary + { + ["parentExecutionId"] = parentExecutionId.ToString(), + }); + + var filter = AuditEndpoints.ParseFilter(query); + + Assert.Equal(parentExecutionId, filter.ParentExecutionId); + } + + [Fact] + public void ParseFilter_UnparseableParentExecutionId_IsDroppedSilently() + { + // Lax-parse contract: an unparseable parentExecutionId is dropped (no 400) — + // mirrors the executionId parse. + var query = new Microsoft.AspNetCore.Http.QueryCollection( + new Dictionary + { + ["parentExecutionId"] = "not-a-guid", + }); + + var filter = AuditEndpoints.ParseFilter(query); + + Assert.Null(filter.ParentExecutionId); + } + [Fact] public async Task Query_RepeatedChannelParams_ReachRepositoryAsMultiValueFilter() { From fb1312d0bfc846be874e6156f3e1d04cbf923920 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 19:12:19 -0400 Subject: [PATCH 17/19] test(auditlog): end-to-end ParentExecutionId correlation + docs --- CLAUDE.md | 1 + docs/requirements/Component-AuditLog.md | 18 + .../ParentExecutionIdCorrelationTests.cs | 562 ++++++++++++++++++ 3 files changed, 581 insertions(+) create mode 100644 tests/ScadaLink.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index f60e607..4fe1684 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,6 +133,7 @@ This project contains design documentation for a distributed SCADA system built - 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). +- `ParentExecutionId` (`uniqueidentifier NULL`) is the cross-execution spawn pointer — every row of a spawned run carries the spawner's `ExecutionId`; first cut bridges the inbound API → routed-site-script case (the routed run records the inbound request's `ExecutionId`; the inbound row stays top-level / NULL); `IX_AuditLog_ParentExecution` backs the filter + the recursive execution-tree walk; tag cascade deferred. - 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. diff --git a/docs/requirements/Component-AuditLog.md b/docs/requirements/Component-AuditLog.md index 7fdeb66..1b6debf 100644 --- a/docs/requirements/Component-AuditLog.md +++ b/docs/requirements/Component-AuditLog.md @@ -84,6 +84,7 @@ row per lifecycle event across all channels. | `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. | +| `ParentExecutionId` | `uniqueidentifier` NULL | The `ExecutionId` of the execution that *spawned* this run — the cross-execution correlation pointer. Set on every row of an inbound-API-routed site script run (= the inbound request's `ExecutionId`); NULL for a top-level run (inbound, tag-change / timer-triggered, un-bridged). | | `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. | @@ -105,6 +106,7 @@ row per lifecycle event across all channels. - `IX_AuditLog_Site_Occurred (SourceSiteId, OccurredAtUtc)` — per-site filters. - `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_ParentExecution (ParentExecutionId)` — cross-execution drilldown: the downward leg of the execution-tree walk seeks on it (`ParentExecutionId = ancestor.ExecutionId`), and it backs the `parentExecutionId` filter. - `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). @@ -149,6 +151,22 @@ The table carries two correlation columns at different granularities: The two are orthogonal: one execution may touch several operations (each with its own `CorrelationId`) yet every resulting row shares the one `ExecutionId`. +**`ParentExecutionId`** adds *cross-execution* correlation on top. `ExecutionId` +is per-run and flat — `WHERE ExecutionId = X` returns everything one run did, but +nothing links a run to the run that *spawned* it. `ParentExecutionId` carries the +spawning execution's `ExecutionId`: a spawned run still gets its own fresh +`ExecutionId`, and every audit row it emits also carries the spawner's id in +`ParentExecutionId`. The first cut bridges the **inbound API → routed-site-script** +case: an inbound request runs a method script that calls `Route.Call`, routing to +a site instance; the routed site script records the inbound request's +`ExecutionId` as its `ParentExecutionId`, while the inbound `InboundRequest` row +itself is top-level (`ParentExecutionId` NULL). The pointer always references the +*immediate* spawner, so a routed run that itself routes onward threads its own +`ExecutionId` — walking `ParentExecutionId → ExecutionId` recursively +reconstructs the call chain as a tree of arbitrary depth. The tag-cascade case +(an attribute write triggering another script) is **deferred** — the model +generalises to it with no schema change once that spawn point is threaded. + ## The Site-Local `AuditLog` (SQLite) A SQLite database file on each site node, alongside the Store-and-Forward diff --git a/tests/ScadaLink.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs b/tests/ScadaLink.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs new file mode 100644 index 0000000..0efc6f1 --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs @@ -0,0 +1,562 @@ +using System.Text; +using System.Text.Json; +using Akka.Actor; +using Akka.TestKit.Xunit2; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +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.Entities.Audit; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Interfaces.Services; +using ScadaLink.Commons.Messages.InboundApi; +using ScadaLink.Commons.Messages.Notification; +using ScadaLink.Commons.Types.Audit; +using ScadaLink.Commons.Types.Enums; +using ScadaLink.ConfigurationDatabase; +using ScadaLink.ConfigurationDatabase.Repositories; +using ScadaLink.ConfigurationDatabase.Tests.Migrations; +using ScadaLink.InboundAPI; +using ScadaLink.InboundAPI.Middleware; +using ScadaLink.NotificationOutbox; +using ScadaLink.NotificationOutbox.Delivery; +using ScadaLink.NotificationOutbox.Messages; +using ScadaLink.SiteRuntime.Scripts; +using ScadaLink.StoreAndForward; + +namespace ScadaLink.AuditLog.Tests.Integration; + +/// +/// Audit Log #23 — ParentExecutionId cross-execution correlation headline +/// end-to-end suite. Verifies the inbound-API → routed-site-script bridge: an +/// inbound HTTP request runs an inbound method script that calls +/// Route.Call into a site instance; the routed site script does a sync +/// ExternalSystem.Call, a cached call and a Notify.Send. Every +/// audit row the routed run produces — site + central, sync + cached lifecycle +/// + NotifySend/NotifyDeliver — must carry +/// equal to the inbound request's +/// , while the routed run has its own +/// distinct and the inbound +/// row is top-level +/// (ParentExecutionId = NULL). +/// +/// +/// +/// This is the integration-level counterpart to : +/// where that suite drives a single run and +/// asserts the shared per-run ExecutionId, this suite spans two +/// executions on opposite sides of the inbound→routed bridge and asserts the +/// cross-execution ParentExecutionId linkage plus +/// . +/// +/// +/// The bridge is exercised through the genuine production glue: +/// +/// the real in a +/// Microsoft.AspNetCore.TestHost pipeline — mints the inbound request's +/// per-request ExecutionId once, stashes it on +/// , and emits the top-level +/// row via the real +/// ; +/// the real + +/// — the executor binds the stashed inbound +/// ExecutionId via , so a +/// Route.To(...).Call(...) inside the inbound script builds a +/// carrying +/// . +/// +/// Only the cross-cluster routing transport is substituted: the test +/// stands in for +/// CommunicationServiceInstanceRouter exactly as the production site +/// (DeploymentManagerActorScriptActorScriptExecutionActor) +/// would — it reads off the +/// wire request and threads it into the routed +/// as parentExecutionId. A multi-node cluster is out of scope for an +/// in-process test (mirroring SiteAuditPushFlowTests's relay). +/// +/// +/// The central audit store is the real over the +/// per-class MSSQL database; the routed run's +/// site rows reach it through the real hot-path + +/// drain, the cached lifecycle rows through +/// the production , and the +/// NotifyDeliver rows through the real central +/// dispatcher. +/// +/// +public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture +{ + private readonly MsSqlMigrationFixture _fixture; + + public ParentExecutionIdCorrelationTests(MsSqlMigrationFixture fixture) + { + _fixture = fixture; + } + + private const string RoutedInstanceCode = "Plant.Pump42"; + private const string RoutedScriptName = "OnInboundRouted"; + private const string ExternalSystemName = "ERP"; + private const string ExternalMethodName = "GetOrder"; + private const string NotifyListName = "ops-team"; + + /// Per-run site id (Guid suffix) so concurrent tests sharing the MSSQL fixture stay isolated. + private static string NewSiteId() => + "test-parentexec-" + Guid.NewGuid().ToString("N").Substring(0, 8); + + private ScadaLinkDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer(_fixture.ConnectionString) + .ConfigureWarnings(w => w.Ignore( + Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)) + .Options; + return new ScadaLinkDbContext(options); + } + + [SkippableFact] + public async Task InboundRoutedRun_AllRoutedRows_CarryInboundExecutionId_AsParentExecutionId() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + + // ── Central — repository + ingest actor + audit writer over the MSSQL fixture ── + var centralServices = new ServiceCollection(); + centralServices.AddDbContext(opts => + opts.UseSqlServer(_fixture.ConnectionString) + .ConfigureWarnings(w => w.Ignore( + Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning))); + centralServices.AddScoped(sp => + new AuditLogRepository(sp.GetRequiredService())); + centralServices.AddScoped(sp => + new SiteCallAuditRepository(sp.GetRequiredService())); + centralServices.AddScoped(sp => + new NotificationOutboxRepository(sp.GetRequiredService())); + centralServices.AddScoped(sp => + new NotificationRepository(sp.GetRequiredService())); + // The NotifyDeliver dispatch path runs through this same long-lived + // provider — a stub adapter that always reports a successful delivery. + centralServices.AddScoped(_ => new AlwaysDeliversAdapter()); + await using var centralProvider = centralServices.BuildServiceProvider(); + + var ingestActor = Sys.ActorOf(Props.Create(() => new AuditLogIngestActor( + (IServiceProvider)centralProvider, + NullLogger.Instance))); + var centralAuditWriter = new CentralAuditWriter( + centralProvider, NullLogger.Instance); + + // ── Site — SQLite audit writer (hot-path) drained to central by the + // real SiteAuditTelemetryActor through the stub gRPC client. The sync + // ApiCall row and the NotifySend row flow through this chain. ── + await using var sqliteWriter = new SqliteAuditWriter( + Options.Create(new SqliteAuditWriterOptions + { + DatabasePath = "ignored", + BatchSize = 64, + ChannelCapacity = 1024, + }), + NullLogger.Instance, + connectionStringOverride: + $"Data Source=file:auditlog-parentexec-{Guid.NewGuid():N}?mode=memory&cache=shared"); + var ring = new RingBufferFallback(); + var siteAuditWriter = new FallbackAuditWriter( + sqliteWriter, ring, new NoOpAuditWriteFailureCounter(), + NullLogger.Instance); + var stubClient = new DirectActorSiteStreamAuditClient(ingestActor); + Sys.ActorOf(Props.Create(() => new SiteAuditTelemetryActor( + (ISiteAuditQueue)sqliteWriter, + stubClient, + Options.Create(new SiteAuditTelemetryOptions + { + BatchSize = 256, + BusyIntervalSeconds = 1, + IdleIntervalSeconds = 1, + }), + NullLogger.Instance))); + + // Cached-call telemetry: production forwarder + dispatcher that also + // pushes each combined packet through the stub client into the central + // dual-write transaction (same wiring CombinedTelemetryHarness uses). + var cachedForwarder = new CombinedTelemetryDispatcher( + new CachedCallTelemetryForwarder( + siteAuditWriter, trackingStore: null, + NullLogger.Instance), + stubClient); + + // Site Store-and-Forward — Notify.Send buffers a NotificationSubmit here. + using var safKeepAlive = new Microsoft.Data.Sqlite.SqliteConnection( + $"Data Source=parentexec-saf-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"); + safKeepAlive.Open(); + var safStorage = new StoreAndForwardStorage( + safKeepAlive.ConnectionString, NullLogger.Instance); + await safStorage.InitializeAsync(); + var storeAndForward = new StoreAndForwardService( + safStorage, + new StoreAndForwardOptions + { + DefaultRetryInterval = TimeSpan.Zero, + DefaultMaxRetries = 3, + RetryTimerInterval = TimeSpan.FromMinutes(10), + }, + NullLogger.Instance); + + // ── Outbound external-system client (routed run): sync Call succeeds, + // CachedCall completes immediately (WasBuffered=false) so the script + // helper emits the Submit + Attempted + CachedResolve lifecycle. ── + var externalClient = Substitute.For(); + externalClient + .CallAsync(ExternalSystemName, ExternalMethodName, + Arg.Any?>(), Arg.Any()) + .Returns(new ExternalCallResult(true, "{\"ok\":true}", null)); + externalClient + .CachedCallAsync(ExternalSystemName, ExternalMethodName, + Arg.Any?>(), + Arg.Any(), Arg.Any(), + Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ExternalCallResult(true, "{\"ok\":true}", null, WasBuffered: false)); + + // ── The routing transport stand-in: builds the routed ScriptRuntimeContext + // carrying RouteToCallRequest.ParentExecutionId — exactly what the + // production site handler (DeploymentManagerActor) does. ── + var router = new BridgingInstanceRouter( + siteId, + externalClient, + siteAuditWriter, + cachedForwarder, + storeAndForward); + + // ── The inbound API method script: it calls Route.Call into the site + // instance. The real InboundScriptExecutor binds the inbound request's + // ExecutionId onto the RouteHelper, so the routed call carries it as + // ParentExecutionId. ── + var inboundMethod = new ScadaLink.Commons.Entities.InboundApi.ApiMethod( + "submitOrder", + $"return await Route.To(\"{RoutedInstanceCode}\").Call(\"{RoutedScriptName}\", new {{ order = 7 }});"); + var locator = Substitute.For(); + locator.GetSiteIdForInstanceAsync(RoutedInstanceCode, Arg.Any()) + .Returns(siteId); + var scriptExecutor = new InboundScriptExecutor( + NullLogger.Instance, + new ServiceCollection().BuildServiceProvider()); + Assert.True(scriptExecutor.CompileAndRegister(inboundMethod)); + + // ── Act — issue the inbound HTTP request through a TestHost pipeline + // fronted by the real AuditWriteMiddleware. The endpoint handler reads + // the middleware-stashed inbound ExecutionId and runs the inbound + // method script with it as parentExecutionId. ── + using var host = await BuildInboundHostAsync(centralAuditWriter, async ctx => + { + var inboundExecutionId = (Guid)ctx.Items[AuditWriteMiddleware.InboundExecutionIdItemKey]!; + var route = new RouteHelper(locator, router); + var result = await scriptExecutor.ExecuteAsync( + inboundMethod, + new Dictionary(), + route, + TimeSpan.FromSeconds(30), + ctx.RequestAborted, + parentExecutionId: inboundExecutionId); + + ctx.Response.StatusCode = result.Success ? 200 : 500; + await ctx.Response.WriteAsync(result.Success ? "ok" : "fail"); + }); + + var client = host.GetTestClient(); + var response = await client.PostAsync( + "/api/submitOrder", + new StringContent("{}", Encoding.UTF8, "application/json")); + Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode); + + // The routed run produced a NotifySend that buffered a NotificationSubmit + // into S&F. Drain that genuine site-produced submission to the central + // NotificationOutboxActor so the NotifyDeliver dispatch rows materialise. + await ForwardBufferedNotificationToCentralAsync( + storeAndForward, router.NotificationId!, centralProvider, centralAuditWriter); + + // ── Assert ────────────────────────────────────────────────────────── + await AwaitAssertAsync(async () => + { + await using var readContext = CreateContext(); + var repo = new AuditLogRepository(readContext); + + // Every audit row this site produced (sync ApiCall + cached lifecycle + // + NotifySend) plus the central NotifyDeliver rows. + var siteRows = await repo.QueryAsync( + new AuditLogQueryFilter(SourceSiteIds: new[] { siteId }), + new AuditLogPaging(PageSize: 100)); + + // sync ApiCall (1) + cached Submit/Attempted/Resolve (3) + NotifySend (1) + // + NotifyDeliver Attempted/Delivered (2) = 7 rows for the routed run. + Assert.True(siteRows.Count == 7, + "Expected 7 routed-run audit rows; saw: " + + string.Join(", ", siteRows.Select(r => $"{r.Channel}/{r.Kind}/{r.Status}"))); + Assert.Single(siteRows, r => r.Channel == AuditChannel.ApiOutbound && r.Kind == AuditKind.ApiCall); + Assert.Single(siteRows, r => r.Kind == AuditKind.CachedSubmit); + Assert.Single(siteRows, r => r.Kind == AuditKind.CachedResolve); + Assert.Single(siteRows, r => r.Kind == AuditKind.NotifySend); + Assert.Equal(2, siteRows.Count(r => r.Kind == AuditKind.NotifyDeliver)); + + // CORE PROMISE: every routed-run row carries the SAME non-null + // ParentExecutionId — the inbound request's ExecutionId. + var parentIds = siteRows.Select(r => r.ParentExecutionId).Distinct().ToList(); + Assert.Single(parentIds); + Assert.NotNull(parentIds[0]); + var inboundExecutionId = parentIds[0]!.Value; + + // The routed run has its OWN distinct ExecutionId — not the parent's. + var routedExecutionIds = siteRows + .Select(r => r.ExecutionId) + .Distinct() + .ToList(); + Assert.Single(routedExecutionIds); + Assert.NotNull(routedExecutionIds[0]); + var routedExecutionId = routedExecutionIds[0]!.Value; + Assert.NotEqual(inboundExecutionId, routedExecutionId); + + // The inbound request's own InboundRequest row is TOP-LEVEL — + // ExecutionId = the propagated id, ParentExecutionId = NULL. + var inboundRows = await repo.QueryAsync( + new AuditLogQueryFilter(ExecutionId: inboundExecutionId), + new AuditLogPaging(PageSize: 10)); + var inboundRow = Assert.Single(inboundRows, + r => r.Channel == AuditChannel.ApiInbound && r.Kind == AuditKind.InboundRequest); + Assert.Equal(AuditStatus.Delivered, inboundRow.Status); + Assert.Null(inboundRow.ParentExecutionId); + + // The parentExecutionId filter pulls the routed run's complete + // trust-boundary footprint (all 7 routed rows, none of the inbound). + var byParent = await repo.QueryAsync( + new AuditLogQueryFilter(ParentExecutionId: inboundExecutionId), + new AuditLogPaging(PageSize: 100)); + Assert.Equal(7, byParent.Count); + Assert.All(byParent, r => Assert.Equal(routedExecutionId, r.ExecutionId)); + + // GetExecutionTreeAsync returns BOTH executions in one chain — + // inbound (root) and routed (child), regardless of entry point. + var treeFromChild = await repo.GetExecutionTreeAsync(routedExecutionId); + AssertChain(treeFromChild, inboundExecutionId, routedExecutionId); + var treeFromRoot = await repo.GetExecutionTreeAsync(inboundExecutionId); + AssertChain(treeFromRoot, inboundExecutionId, routedExecutionId); + }, TimeSpan.FromSeconds(30)); + } + + /// + /// Asserts the execution tree is the expected two-node inbound→routed chain: + /// the inbound execution is the root (ParentExecutionId = NULL) and the + /// routed execution's ParentExecutionId points back at it. + /// + private static void AssertChain( + IReadOnlyList tree, + Guid inboundExecutionId, + Guid routedExecutionId) + { + Assert.Equal(2, tree.Count); + var root = Assert.Single(tree, n => n.ExecutionId == inboundExecutionId); + Assert.Null(root.ParentExecutionId); + var child = Assert.Single(tree, n => n.ExecutionId == routedExecutionId); + Assert.Equal(inboundExecutionId, child.ParentExecutionId); + } + + /// + /// Spins up a minimal in-memory ASP.NET host whose pipeline mirrors the + /// production inbound-API arrangement: routing → the real + /// → the POST /api/{methodName} + /// endpoint. The middleware mints + stashes the inbound request's + /// ExecutionId and emits the top-level + /// row via the supplied . + /// + private static async Task BuildInboundHostAsync( + ICentralAuditWriter centralAuditWriter, + RequestDelegate endpointHandler) + { + var hostBuilder = new HostBuilder() + .ConfigureWebHost(webBuilder => + { + webBuilder + .UseTestServer() + .ConfigureServices(services => + { + services.AddSingleton(centralAuditWriter); + services.AddRouting(); + }) + .Configure(app => + { + app.UseRouting(); + app.UseAuditWriteMiddleware(); + app.UseEndpoints(endpoints => + { + endpoints.MapPost("/api/{methodName}", endpointHandler); + }); + }); + }); + + return await hostBuilder.StartAsync(); + } + + /// + /// Reads the genuine site-produced the routed + /// Notify.Send buffered into Store-and-Forward, then drives it through + /// a real central so the + /// dispatch rows materialise. The + /// dispatcher echoes OriginParentExecutionId off the + /// NotificationSubmit onto every NotifyDeliver row — the + /// cross-execution linkage under test on the central side. + /// + private async Task ForwardBufferedNotificationToCentralAsync( + StoreAndForwardService storeAndForward, + string notificationId, + IServiceProvider centralProvider, + ICentralAuditWriter centralAuditWriter) + { + var buffered = await storeAndForward.GetMessageByIdAsync(notificationId); + Assert.NotNull(buffered); + var submit = JsonSerializer.Deserialize(buffered!.PayloadJson); + Assert.NotNull(submit); + // The routed Notify.Send stamped the inbound request's ExecutionId as the + // submission's OriginParentExecutionId — proven separately on the + // NotifyDeliver rows, but asserted here too as the central handoff input. + Assert.NotNull(submit!.OriginParentExecutionId); + + // The outbox actor runs over the long-lived central provider (which + // carries the AlwaysDeliversAdapter) so the dispatch sweep — launched + // asynchronously by the DispatchTick — still has a live IServiceProvider + // to resolve its per-sweep scope from. + var outboxActor = Sys.ActorOf(Props.Create(() => new NotificationOutboxActor( + centralProvider, + new NotificationOutboxOptions + { + // Long timers so PreStart's scheduled ticks never fire — the + // test drives ingest + dispatch explicitly. + DispatchInterval = TimeSpan.FromHours(1), + PurgeInterval = TimeSpan.FromDays(1), + }, + centralAuditWriter, + NullLogger.Instance))); + + // Ingest the genuine site submission, then run one dispatch sweep. + var ack = await outboxActor.Ask( + submit, TimeSpan.FromSeconds(15)); + Assert.True(ack.Accepted, ack.Error); + outboxActor.Tell(InternalMessages.DispatchTick.Instance); + } + + /// + /// Stub that always reports a + /// successful delivery — a single dispatch sweep then yields one + /// + one + /// row. + /// + private sealed class AlwaysDeliversAdapter : INotificationDeliveryAdapter + { + public NotificationType Type => NotificationType.Email; + + public Task DeliverAsync( + ScadaLink.Commons.Entities.Notifications.Notification notification, + CancellationToken cancellationToken = default) + => Task.FromResult(DeliveryOutcome.Success("ops@example.com")); + } + + /// + /// In-process stand-in for the cross-cluster routing transport + /// (CommunicationServiceInstanceRouter → + /// CommunicationService → site DeploymentManagerActor). On a + /// routed Call it does exactly what the production site handler does: + /// it reads off the wire + /// request and threads it into a fresh routed + /// as parentExecutionId, then runs the routed script's three + /// trust-boundary actions (sync ExternalSystem.Call, a cached call and + /// a Notify.Send). The routed context still mints its OWN fresh + /// ExecutionId — only the parent pointer is inherited. + /// + private sealed class BridgingInstanceRouter : IInstanceRouter + { + private readonly string _siteId; + private readonly IExternalSystemClient _externalClient; + private readonly IAuditWriter _auditWriter; + private readonly ICachedCallTelemetryForwarder _cachedForwarder; + private readonly StoreAndForwardService _storeAndForward; + + /// + /// The NotificationId the routed Notify.Send minted, captured + /// so the test can drain the buffered . + /// + public string? NotificationId { get; private set; } + + public BridgingInstanceRouter( + string siteId, + IExternalSystemClient externalClient, + IAuditWriter auditWriter, + ICachedCallTelemetryForwarder cachedForwarder, + StoreAndForwardService storeAndForward) + { + _siteId = siteId; + _externalClient = externalClient; + _auditWriter = auditWriter; + _cachedForwarder = cachedForwarder; + _storeAndForward = storeAndForward; + } + + public async Task RouteToCallAsync( + string siteId, RouteToCallRequest request, CancellationToken cancellationToken) + { + var compilationService = new ScriptCompilationService( + NullLogger.Instance); + var sharedScriptLibrary = new SharedScriptLibrary( + compilationService, NullLogger.Instance); + + // Mirror DeploymentManagerActor → ScriptActor → ScriptExecutionActor: + // the routed script execution gets its OWN fresh ExecutionId, and the + // inbound request's ExecutionId arrives as ParentExecutionId. + var routedContext = new ScriptRuntimeContext( + ActorRefs.Nobody, + ActorRefs.Nobody, + sharedScriptLibrary, + currentCallDepth: 0, + maxCallDepth: 10, + askTimeout: TimeSpan.FromSeconds(5), + instanceName: request.InstanceUniqueName, + logger: NullLogger.Instance, + externalSystemClient: _externalClient, + databaseGateway: null, + storeAndForward: _storeAndForward, + siteCommunicationActor: null, + siteId: _siteId, + sourceScript: $"ScriptActor:{request.ScriptName}", + auditWriter: _auditWriter, + operationTrackingStore: null, + cachedForwarder: _cachedForwarder, + executionId: null, + parentExecutionId: request.ParentExecutionId); + + // The routed site script's body: a sync ExternalSystem.Call, a cached + // call, and a Notify.Send — three distinct trust-boundary actions of + // the one routed execution. + await routedContext.ExternalSystem.Call(ExternalSystemName, ExternalMethodName); + await routedContext.ExternalSystem.CachedCall(ExternalSystemName, ExternalMethodName); + NotificationId = await routedContext.Notify + .To(NotifyListName) + .Send("Routed run alert", "inbound-routed script fired"); + + return new RouteToCallResponse( + request.CorrelationId, true, "routed-ok", null, DateTimeOffset.UtcNow); + } + + public Task RouteToGetAttributesAsync( + string siteId, RouteToGetAttributesRequest request, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task RouteToSetAttributesAsync( + string siteId, RouteToSetAttributesRequest request, CancellationToken cancellationToken) + => throw new NotSupportedException(); + } +} From 933f0484ba34abac78dfcc23c2b29257267c51f6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 20:09:54 -0400 Subject: [PATCH 18/19] test(auditlog): ParentExecutionId e2e waits on audit kinds, not a row count The headline ParentExecutionIdCorrelationTests intermittently failed under full-suite parallel load, seeing 6 of 7 routed-run rows (NotifySend missing). Root cause: WaitForSiteRowsPersistedAsync checked only a row *count*, which a partial snapshot could satisfy before the last-emitted NotifySend row settled, letting the SiteAuditTelemetryActor drain a partial batch. Fix is test-only: wait on the specific audit Kinds (guaranteeing NotifySend is durably in SQLite before the assertion) and widen the assertion ceiling 30s -> 90s for drain headroom under load. Also drops leftover // DIAG sampler debug scaffolding. --- .../ParentExecutionIdCorrelationTests.cs | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/tests/ScadaLink.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs b/tests/ScadaLink.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs index 0efc6f1..1207c88 100644 --- a/tests/ScadaLink.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Integration/ParentExecutionIdCorrelationTests.cs @@ -277,6 +277,20 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture @@ -450,6 +464,48 @@ public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture + /// Polls the site SQLite hot-path until every audit + /// the routed run is expected to emit — sync ApiCall, the cached + /// CachedSubmit/ApiCallCached/CachedResolve lifecycle, + /// and NotifySend — is durably present (Pending or Forwarded). + /// + /// + /// The routed run's sync-ApiCall and NotifySend audit rows are + /// written fire-and-forget (the script call must not block on the audit + /// writer — alog.md §7), so the routed RouteToCallAsync returns + /// before the background writer loop has committed those rows. + /// NotifySend is emitted last and therefore settles last. This wait + /// asserts the specific Kinds are present, not merely a row count: a + /// bare count could be satisfied while the last-emitted NotifySend + /// row was still in flight, letting the SiteAuditTelemetryActor drain + /// only a partial snapshot and leave NotifySend stranded for a later + /// tick — the emit-vs-drain race that failed this test under full-suite load. + /// + private async Task WaitForSiteRowsPersistedAsync(SqliteAuditWriter sqliteWriter) + { + var expectedKinds = new[] + { + AuditKind.ApiCall, AuditKind.CachedSubmit, AuditKind.ApiCallCached, + AuditKind.CachedResolve, AuditKind.NotifySend, + }; + await AwaitAssertAsync( + async () => + { + var pending = await sqliteWriter.ReadPendingAsync(256); + var forwarded = await sqliteWriter.ReadForwardedAsync(256); + var kinds = pending.Concat(forwarded).Select(r => r.Kind).ToHashSet(); + var missing = expectedKinds.Where(k => !kinds.Contains(k)).ToList(); + Assert.True( + missing.Count == 0, + "Expected every routed-run audit Kind durably in SQLite; missing: " + + string.Join(", ", missing) + + $" (saw {pending.Count} Pending + {forwarded.Count} Forwarded)."); + }, + TimeSpan.FromSeconds(30), + TimeSpan.FromMilliseconds(50)); + } + /// /// Stub that always reports a /// successful delivery — a single dispatch sweep then yields one From 9ec83d50700c9db643f5b5c8656fa5c1ed924a59 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 20:14:31 -0400 Subject: [PATCH 19/19] docs(auditlog): generalize two stale XML-doc comments - AddColumnIfMissing is now shared by ExecutionId and ParentExecutionId; drop the ExecutionId-specific tag. - AuditLogRepository.GetExecutionTreeAsync doc no longer hardcodes the MAXRECURSION literal; reference the ExecutionChainMaxDepth const instead. --- src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs | 5 +++-- .../Repositories/AuditLogRepository.cs | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs b/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs index f38d99f..49a6d86 100644 --- a/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs +++ b/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs @@ -147,8 +147,9 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable } /// - /// Audit Log #23 (ExecutionId): adds a column to AuditLog only when - /// it is not already present. SQLite lacks ADD COLUMN IF NOT EXISTS, + /// Audit Log #23: additively adds a column to AuditLog only when + /// it is not already present (used for ExecutionId and + /// ParentExecutionId). SQLite lacks ADD COLUMN IF NOT EXISTS, /// so the schema is probed via PRAGMA table_info first. Idempotent — /// safe to run on every . Mirrors /// StoreAndForwardStorage.AddColumnIfMissingAsync; kept synchronous diff --git a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs index 76d4b47..33dad3a 100644 --- a/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs +++ b/src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs @@ -581,8 +581,9 @@ VALUES /// and joining edge.ParentExecutionId = chain.ExecutionId to /// enumerate every descendant. Recursing over edges rather than raw rows /// keeps the recursion one path wide per execution. It is bounded by - /// OPTION (MAXRECURSION 32) — corrupt cyclic data raises a - /// (msg 530) rather than spinning. + /// OPTION (MAXRECURSION ...) at + /// — corrupt cyclic data raises a (msg 530) + /// rather than spinning. /// /// /// The chain's full execution-id set is every edge's ExecutionId