Merge branch 'feature/audit-parent-executionid': ParentExecutionId cross-execution audit correlation
This commit is contained in:
@@ -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.
|
||||
|
||||
222
docs/plans/2026-05-21-audit-parent-executionid-design.md
Normal file
222
docs/plans/2026-05-21-audit-parent-executionid-design.md
Normal file
@@ -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=<guid>`, 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=<node>`. 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=<parentId>`).
|
||||
|
||||
### 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 <guid>`;
|
||||
`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`).
|
||||
220
docs/plans/2026-05-21-audit-parent-executionid.md
Normal file
220
docs/plans/2026-05-21-audit-parent-executionid.md
Normal file
@@ -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 <path>` — 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<IReadOnlyList<ExecutionTreeNode>> 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=<guid>`; `BuildExportUrl` emits it.
|
||||
- Add a "View parent execution" row/drilldown action (in `AuditDrilldownDrawer` and/or a grid row action) linking `/audit/log?executionId=<ParentExecutionId>`, 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=<guid>`; 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=<node>`.
|
||||
- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditDrilldownDrawer.razor` (+ `.razor.cs`) and/or `AuditResultsGrid` — add a "View execution chain" action linking `/audit/execution-tree?executionId=<ExecutionId of the row>`.
|
||||
- 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 <guid>`; `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=<guid>` 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.
|
||||
19
docs/plans/2026-05-21-audit-parent-executionid.md.tasks.json
Normal file
19
docs/plans/2026-05-21-audit-parent-executionid.md.tasks.json
Normal file
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,11 +136,20 @@ 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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ExecutionId): adds a column to <c>AuditLog</c> only when
|
||||
/// it is not already present. SQLite lacks <c>ADD COLUMN IF NOT EXISTS</c>,
|
||||
/// Audit Log #23: additively adds a column to <c>AuditLog</c> only when
|
||||
/// it is not already present (used for <c>ExecutionId</c> and
|
||||
/// <c>ParentExecutionId</c>). SQLite lacks <c>ADD COLUMN IF NOT EXISTS</c>,
|
||||
/// so the schema is probed via <c>PRAGMA table_info</c> first. Idempotent —
|
||||
/// safe to run on every <see cref="InitializeSchema"/>. Mirrors
|
||||
/// <c>StoreAndForwardStorage.AddColumnIfMissingAsync</c>; kept synchronous
|
||||
@@ -263,13 +273,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 +304,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 +330,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 +389,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 +438,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 +525,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 +703,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
||||
Extra = reader.IsDBNull(18) ? null : reader.GetString(18),
|
||||
ForwardState = Enum.Parse<AuditForwardState>(reader.GetString(19)),
|
||||
ExecutionId = reader.IsDBNull(20) ? null : Guid.Parse(reader.GetString(20)),
|
||||
ParentExecutionId = reader.IsDBNull(21) ? null : Guid.Parse(reader.GetString(21)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -60,6 +60,7 @@ public static class AuditCommands
|
||||
var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" };
|
||||
var correlationIdOption = new Option<string?>("--correlation-id") { Description = "Filter by correlation ID" };
|
||||
var executionIdOption = new Option<string?>("--execution-id") { Description = "Filter by execution ID" };
|
||||
var parentExecutionIdOption = new Option<string?>("--parent-execution-id") { Description = "Filter by parent execution ID" };
|
||||
var errorsOnlyOption = new Option<bool>("--errors-only") { Description = "Show only failed events (status=Failed; overrides --status)" };
|
||||
var pageSizeOption = new Option<int>("--page-size") { Description = "Events per page (1-1000)" };
|
||||
pageSizeOption.DefaultValueFactory = _ => 100;
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -58,6 +58,9 @@
|
||||
<dt class="col-4 text-muted fw-normal">ExecutionId</dt>
|
||||
<dd class="col-8 font-monospace" data-test="field-ExecutionId">@(Event.ExecutionId?.ToString() ?? "—")</dd>
|
||||
|
||||
<dt class="col-4 text-muted fw-normal">ParentExecutionId</dt>
|
||||
<dd class="col-8 font-monospace" data-test="field-ParentExecutionId">@(Event.ParentExecutionId?.ToString() ?? "—")</dd>
|
||||
|
||||
<dt class="col-4 text-muted fw-normal">OccurredAtUtc</dt>
|
||||
<dd class="col-8 font-monospace" data-test="field-OccurredAtUtc">@FormatTimestamp(Event.OccurredAtUtc)</dd>
|
||||
|
||||
@@ -162,6 +165,22 @@
|
||||
View this execution
|
||||
</button>
|
||||
}
|
||||
@if (Event.ParentExecutionId is not null)
|
||||
{
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
data-test="view-parent-execution"
|
||||
@onclick="ViewParentExecution">
|
||||
View parent execution
|
||||
</button>
|
||||
}
|
||||
@if (Event.ExecutionId is not null)
|
||||
{
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
data-test="view-execution-chain"
|
||||
@onclick="ViewExecutionChain">
|
||||
View execution chain
|
||||
</button>
|
||||
}
|
||||
<button class="btn btn-primary btn-sm ms-auto"
|
||||
data-test="drawer-close-footer"
|
||||
@onclick="HandleClose">
|
||||
|
||||
@@ -49,7 +49,10 @@ namespace ScadaLink.CentralUI.Components.Audit;
|
||||
/// the "Show all events" button navigates to
|
||||
/// <c>/audit/log?correlationId={id}</c>. Likewise, when
|
||||
/// <see cref="AuditEvent.ExecutionId"/> is set the "View this execution"
|
||||
/// button navigates to <c>/audit/log?executionId={id}</c>. Both are deep
|
||||
/// button navigates to <c>/audit/log?executionId={id}</c>. Likewise, when
|
||||
/// <see cref="AuditEvent.ParentExecutionId"/> is set the "View parent
|
||||
/// execution" button navigates to <c>/audit/log?executionId={parentId}</c>
|
||||
/// — the spawner's id used as the per-run drill-in target. All are deep
|
||||
/// links the Audit Log page deserializes on init (Bundle D) and auto-loads.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
@@ -291,6 +294,37 @@ public partial class AuditDrilldownDrawer
|
||||
Navigation.NavigateTo(uri);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drill-in to the spawner execution: a routed (child) row carries a non-null
|
||||
/// <see cref="AuditEvent.ParentExecutionId"/>. Navigates to
|
||||
/// <c>/audit/log?executionId={ParentExecutionId}</c> so the user sees the
|
||||
/// spawner execution's own rows — the parent's id becomes the <c>?executionId=</c>
|
||||
/// drill-in target. The button is only rendered when
|
||||
/// <see cref="AuditEvent.ParentExecutionId"/> is non-null, so this is total.
|
||||
/// </summary>
|
||||
private void ViewParentExecution()
|
||||
{
|
||||
if (Event?.ParentExecutionId is not { } parentExec) return;
|
||||
var uri = $"/audit/log?executionId={parentExec}";
|
||||
Navigation.NavigateTo(uri);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drill-in to the execution-chain TREE view (Audit Log ParentExecutionId
|
||||
/// feature, Task 10). Navigates to
|
||||
/// <c>/audit/execution-tree?executionId={ExecutionId}</c> — the tree page
|
||||
/// resolves the whole chain rooted at the topmost ancestor and renders it
|
||||
/// expandably, with this row's execution highlighted. The button is only
|
||||
/// rendered when <see cref="AuditEvent.ExecutionId"/> is non-null, so this
|
||||
/// is total.
|
||||
/// </summary>
|
||||
private void ViewExecutionChain()
|
||||
{
|
||||
if (Event?.ExecutionId is not { } exec) return;
|
||||
var uri = $"/audit/execution-tree?executionId={exec}";
|
||||
Navigation.NavigateTo(uri);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a cURL command from an audit event. The URL comes from
|
||||
/// <c>Target</c>; when the RequestSummary parses as
|
||||
|
||||
@@ -127,6 +127,16 @@
|
||||
placeholder="paste GUID…" @bind="_model.ExecutionId" />
|
||||
</div>
|
||||
|
||||
@* 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. *@
|
||||
<div class="col-auto" data-test="filter-parent-execution-id">
|
||||
<label class="form-label small mb-1" for="audit-parent-execution-id">Parent execution ID</label>
|
||||
<input id="audit-parent-execution-id" type="text"
|
||||
class="form-control form-control-sm font-monospace"
|
||||
placeholder="paste GUID…" @bind="_model.ParentExecutionId" />
|
||||
</div>
|
||||
|
||||
<div class="col-auto" data-test="filter-errors-only">
|
||||
<div class="form-check mb-1">
|
||||
<input class="form-check-input" type="checkbox" id="audit-errors-only"
|
||||
|
||||
@@ -136,6 +136,7 @@ public partial class AuditFilterBar
|
||||
_model.TargetSearch = string.Empty;
|
||||
_model.ActorSearch = string.Empty;
|
||||
_model.ExecutionId = string.Empty;
|
||||
_model.ParentExecutionId = string.Empty;
|
||||
_model.ErrorsOnly = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,15 @@ public sealed class AuditQueryModel
|
||||
/// </summary>
|
||||
public string ExecutionId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Paste-in ParentExecutionId filter — the operator pastes the spawner
|
||||
/// execution's Guid to find every run it spawned. Stored as free text;
|
||||
/// <see cref="ToFilter"/> lax-parses it through
|
||||
/// <see cref="Guid.TryParse(string?, out Guid)"/> so a blank or unparseable
|
||||
/// value simply yields no constraint, mirroring <see cref="ExecutionId"/>.
|
||||
/// </summary>
|
||||
public string ParentExecutionId { get; set; } = string.Empty;
|
||||
|
||||
public bool ErrorsOnly { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -132,6 +132,18 @@
|
||||
<span class="small text-muted">—</span>
|
||||
}
|
||||
break;
|
||||
case "ParentExecutionId":
|
||||
@if (row.ParentExecutionId is { } parentExecutionId)
|
||||
{
|
||||
<span class="small font-monospace"
|
||||
data-test="parent-execution-id-@row.EventId"
|
||||
title="@parentExecutionId">@ShortGuid(parentExecutionId)</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="small text-muted">—</span>
|
||||
}
|
||||
break;
|
||||
case "DurationMs":
|
||||
<span class="small font-monospace">@(row.DurationMs?.ToString() ?? "—")</span>
|
||||
break;
|
||||
|
||||
@@ -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
|
||||
/// <see cref="Services.IAuditLogQueryService"/>
|
||||
/// — never to <c>IAuditLogRepository</c> 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"),
|
||||
|
||||
123
src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor
Normal file
123
src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor
Normal file
@@ -0,0 +1,123 @@
|
||||
@using ScadaLink.Commons.Types.Audit
|
||||
|
||||
@* Execution-chain tree (Audit Log ParentExecutionId feature, Task 10).
|
||||
A custom recursive Blazor tree: the host hands in the FLAT ExecutionTreeNode
|
||||
list the repository returns; this component assembles it into a tree (joining
|
||||
ParentExecutionId → a parent's ExecutionId), then renders depth-first.
|
||||
|
||||
Recursion is expressed by the component rendering <ExecutionTree> for each
|
||||
child subtree. To keep that recursion finite even on corrupt/cyclic input,
|
||||
the assembled subtree is computed ONCE at the root (Depth == 0) and threaded
|
||||
downward via the PreBuiltRoots parameter — child instances never re-run the
|
||||
flat-list assembly, and the assembly itself tracks visited ExecutionIds so a
|
||||
cycle is broken on first revisit. *@
|
||||
|
||||
@if (_rootsToRender.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
<ul class="execution-tree @(Depth == 0 ? "execution-tree--root" : "")"
|
||||
data-test="execution-tree@(Depth == 0 ? "" : "-subtree")">
|
||||
@foreach (var subtree in _rootsToRender)
|
||||
{
|
||||
var node = subtree.Node;
|
||||
var isCurrent = node.ExecutionId == ArrivedFromExecutionId;
|
||||
var isStub = node.RowCount == 0;
|
||||
<li class="execution-tree-item" @key="node.ExecutionId">
|
||||
<div class="execution-tree-node @(isCurrent ? "execution-tree-node--current" : "") @(isStub ? "execution-tree-node--stub" : "")"
|
||||
data-test="tree-node-@node.ExecutionId">
|
||||
@if (subtree.Children.Count > 0)
|
||||
{
|
||||
<button type="button"
|
||||
class="execution-tree-toggle"
|
||||
data-test="tree-toggle-@node.ExecutionId"
|
||||
aria-expanded="@(IsExpanded(node.ExecutionId) ? "true" : "false")"
|
||||
aria-label="@(IsExpanded(node.ExecutionId) ? "Collapse" : "Expand") child executions"
|
||||
@onclick="() => ToggleExpand(node.ExecutionId)">
|
||||
<span class="execution-tree-toggle-glyph" aria-hidden="true">
|
||||
@(IsExpanded(node.ExecutionId) ? "−" : "+")
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="execution-tree-toggle execution-tree-toggle--leaf" aria-hidden="true"></span>
|
||||
}
|
||||
|
||||
<div class="execution-tree-body">
|
||||
<div class="execution-tree-headline">
|
||||
<a class="execution-tree-link font-monospace"
|
||||
data-test="tree-node-link-@node.ExecutionId"
|
||||
href="@AuditLogUrl(node.ExecutionId)"
|
||||
title="Open the Audit Log filtered to execution @node.ExecutionId">
|
||||
@ShortId(node.ExecutionId)
|
||||
</a>
|
||||
@if (isCurrent)
|
||||
{
|
||||
<span class="badge text-bg-primary execution-tree-tag"
|
||||
data-test="tree-current-tag-@node.ExecutionId">Arrived from</span>
|
||||
}
|
||||
@if (isStub)
|
||||
{
|
||||
<span class="badge text-bg-secondary execution-tree-tag"
|
||||
data-test="stub-node-@node.ExecutionId">No audited actions</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="execution-tree-rowcount text-muted small"
|
||||
data-test="tree-rowcount-@node.ExecutionId">
|
||||
@node.RowCount audit @(node.RowCount == 1 ? "row" : "rows")
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (isStub)
|
||||
{
|
||||
<div class="execution-tree-meta text-muted small">
|
||||
Execution with no audited actions — referenced as a parent, but it
|
||||
emitted no audit rows of its own (or its rows have been purged).
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="execution-tree-meta small">
|
||||
<span class="execution-tree-meta-item">
|
||||
<span class="text-muted">Source</span>
|
||||
@(node.SourceSiteId ?? "—")@(node.SourceInstanceId is null ? "" : " / " + node.SourceInstanceId)
|
||||
</span>
|
||||
@if (node.Channels.Count > 0)
|
||||
{
|
||||
<span class="execution-tree-meta-item">
|
||||
<span class="text-muted">Channels</span>
|
||||
@string.Join(", ", node.Channels)
|
||||
</span>
|
||||
}
|
||||
@if (node.Statuses.Count > 0)
|
||||
{
|
||||
<span class="execution-tree-meta-item">
|
||||
<span class="text-muted">Statuses</span>
|
||||
@string.Join(", ", node.Statuses)
|
||||
</span>
|
||||
}
|
||||
<span class="execution-tree-meta-item">
|
||||
<span class="text-muted">Time span</span>
|
||||
@FormatSpan(node.FirstOccurredAtUtc, node.LastOccurredAtUtc)
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@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. *@
|
||||
<ExecutionTree PreBuiltRoots="subtree.Children"
|
||||
ArrivedFromExecutionId="ArrivedFromExecutionId"
|
||||
Depth="Depth + 1" />
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
266
src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.cs
Normal file
266
src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.cs
Normal file
@@ -0,0 +1,266 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using ScadaLink.Commons.Types.Audit;
|
||||
|
||||
namespace ScadaLink.CentralUI.Components.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Recursive Blazor tree component for the execution-chain view (Audit Log
|
||||
/// ParentExecutionId feature, Task 10).
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Flat list → tree.</b> The repository / query service returns the chain as
|
||||
/// a FLAT <see cref="ExecutionTreeNode"/> list (one per distinct execution). The
|
||||
/// root instance (<see cref="Depth"/> == 0) assembles it once in
|
||||
/// <see cref="OnParametersSet"/>: it groups by <see cref="ExecutionTreeNode.ExecutionId"/>,
|
||||
/// links each node to its parent via <see cref="ExecutionTreeNode.ParentExecutionId"/>,
|
||||
/// 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 <see cref="PreBuiltRoots"/>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Cycle safety.</b> The <c>ParentExecutionId</c> graph is acyclic by
|
||||
/// construction, but the UI must not infinite-loop on corrupt data. Assembly
|
||||
/// tracks visited <see cref="ExecutionTreeNode.ExecutionId"/> 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.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Presentation.</b> Each node shows the short execution id (a link to
|
||||
/// <c>/audit/log?executionId={id}</c>), row count, channels/statuses, source
|
||||
/// site/instance, and time span. A stub node (<see cref="ExecutionTreeNode.RowCount"/>
|
||||
/// == 0) is marked "No audited actions". The node the user arrived from
|
||||
/// (<see cref="ArrivedFromExecutionId"/>) is highlighted. Nodes with children
|
||||
/// are expandable; all nodes start expanded so the whole chain is visible.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public partial class ExecutionTree
|
||||
{
|
||||
/// <summary>
|
||||
/// One assembled subtree: a node plus its already-linked child subtrees.
|
||||
/// Recursive — children are themselves <see cref="Subtree"/> values.
|
||||
/// </summary>
|
||||
/// <param name="Node">The execution this subtree is rooted at.</param>
|
||||
/// <param name="Children">
|
||||
/// Child subtrees, ordered by <c>(FirstOccurredAtUtc ?? DateTime.MaxValue,
|
||||
/// ExecutionId)</c> — earliest first-occurrence time first, stub nodes
|
||||
/// (null timestamp) last, with <c>ExecutionId</c> breaking ties.
|
||||
/// </param>
|
||||
public sealed record Subtree(ExecutionTreeNode Node, IReadOnlyList<Subtree> Children);
|
||||
|
||||
/// <summary>
|
||||
/// The flat node list to assemble into a tree. Supplied on the ROOT
|
||||
/// instance only (<see cref="Depth"/> == 0); nested instances receive
|
||||
/// <see cref="PreBuiltRoots"/> instead.
|
||||
/// </summary>
|
||||
[Parameter] public IReadOnlyList<ExecutionTreeNode>? Nodes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Pre-assembled child subtrees, threaded down from a parent
|
||||
/// <see cref="ExecutionTree"/> so nested instances render without
|
||||
/// re-running the flat-list assembly. Null / unused on the root instance.
|
||||
/// </summary>
|
||||
[Parameter] public IReadOnlyList<Subtree>? PreBuiltRoots { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The execution the user drilled in from — its node is visually
|
||||
/// highlighted so the user keeps their bearings within the chain.
|
||||
/// </summary>
|
||||
[Parameter] public Guid ArrivedFromExecutionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c><ul></c> for styling.
|
||||
/// </summary>
|
||||
[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<Subtree> _rootsToRender = Array.Empty<Subtree>();
|
||||
|
||||
// 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<ExecutionTreeNode>? _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<Guid> _collapsed = new();
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// Nested instance: the parent already assembled our subtrees.
|
||||
if (Depth > 0)
|
||||
{
|
||||
_rootsToRender = PreBuiltRoots ?? Array.Empty<Subtree>();
|
||||
return;
|
||||
}
|
||||
|
||||
// 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<ExecutionTreeNode>());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assembles the flat <see cref="ExecutionTreeNode"/> list into a forest of
|
||||
/// <see cref="Subtree"/> 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. 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 <paramref name="nodes"/>
|
||||
/// is therefore placed in the forest exactly once.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<Subtree> BuildForest(IReadOnlyList<ExecutionTreeNode> nodes)
|
||||
{
|
||||
if (nodes.Count == 0)
|
||||
{
|
||||
return Array.Empty<Subtree>();
|
||||
}
|
||||
|
||||
// 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<Guid, ExecutionTreeNode>();
|
||||
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<Guid, List<ExecutionTreeNode>>();
|
||||
var roots = new List<ExecutionTreeNode>();
|
||||
foreach (var node in byId.Values)
|
||||
{
|
||||
if (node.ParentExecutionId is { } parentId && byId.ContainsKey(parentId))
|
||||
{
|
||||
if (!childrenByParent.TryGetValue(parentId, out var bucket))
|
||||
{
|
||||
bucket = new List<ExecutionTreeNode>();
|
||||
childrenByParent[parentId] = bucket;
|
||||
}
|
||||
bucket.Add(node);
|
||||
}
|
||||
else
|
||||
{
|
||||
roots.Add(node);
|
||||
}
|
||||
}
|
||||
|
||||
var visited = new HashSet<Guid>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively builds one <see cref="Subtree"/>, tracking
|
||||
/// <paramref name="visited"/> so a cyclic flat list cannot drive unbounded
|
||||
/// recursion — a node already attached is never descended into again.
|
||||
/// </summary>
|
||||
private static Subtree BuildSubtree(
|
||||
ExecutionTreeNode node,
|
||||
IReadOnlyDictionary<Guid, List<ExecutionTreeNode>> childrenByParent,
|
||||
HashSet<Guid> visited)
|
||||
{
|
||||
visited.Add(node.ExecutionId);
|
||||
|
||||
var children = new List<Subtree>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Audit Log deep link filtered to one execution's rows.</summary>
|
||||
private static string AuditLogUrl(Guid executionId)
|
||||
=> $"/audit/log?executionId={executionId}";
|
||||
|
||||
/// <summary>First 8 hex digits — the short-id presentation used across the Audit UI.</summary>
|
||||
private static string ShortId(Guid value)
|
||||
{
|
||||
var n = value.ToString("N");
|
||||
return n.Length >= 8 ? n[..8] : n;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the [first, last] occurrence span. Both null on a stub node
|
||||
/// (handled by the caller); a single-row execution shows one timestamp.
|
||||
/// </summary>
|
||||
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)}";
|
||||
}
|
||||
|
||||
// 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)
|
||||
=> utc.ToString("yyyy-MM-dd HH:mm:ss'Z'", CultureInfo.InvariantCulture);
|
||||
}
|
||||
137
src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.css
Normal file
137
src/ScadaLink.CentralUI/Components/Audit/ExecutionTree.razor.css
Normal file
@@ -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;
|
||||
}
|
||||
@@ -23,7 +23,9 @@ namespace ScadaLink.CentralUI.Components.Pages.Audit;
|
||||
/// <c>?instance=</c> are read on initialization. Bundle E (M7-T13) extends
|
||||
/// this with <c>?status=</c> so the Health-dashboard Audit error-rate tile can
|
||||
/// drill in to <c>?status=Failed</c>. The ExecutionId follow-up adds
|
||||
/// <c>?executionId=</c> for the "View this execution" drill-in. When any param is present we allocate a
|
||||
/// <c>?executionId=</c> for the "View this execution" drill-in, and the
|
||||
/// ParentExecutionId follow-up adds <c>?parentExecutionId=</c> for the
|
||||
/// "View parent execution" drill-in. When any param is present we allocate a
|
||||
/// fresh <see cref="AuditLogQueryFilter"/> and assign it to
|
||||
/// <see cref="_currentFilter"/>, which kicks the results grid into auto-load
|
||||
/// without the user clicking Apply. Unknown values (e.g. an invalid enum name)
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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)));
|
||||
|
||||
@@ -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
|
||||
|
||||
<PageTitle>Execution Chain</PageTitle>
|
||||
|
||||
@* 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). *@
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<h1 class="h4 mb-1">Execution Chain</h1>
|
||||
<p class="text-muted small mb-3">
|
||||
The full chain of script / inbound-request executions linked by
|
||||
<span class="font-monospace">ParentExecutionId</span>, rooted at the
|
||||
topmost ancestor. Select an execution to open the Audit Log filtered to
|
||||
its rows.
|
||||
</p>
|
||||
|
||||
@if (_executionId is null)
|
||||
{
|
||||
@* No (or unparseable) ?executionId= — render guidance rather than an
|
||||
empty tree. Mirrors the Audit Log page's silently-drop contract. *@
|
||||
<div class="alert alert-secondary small" data-test="execution-tree-no-id">
|
||||
No execution selected. Open this view from an audit row's
|
||||
<strong>View execution chain</strong> action.
|
||||
</div>
|
||||
}
|
||||
else if (_loading)
|
||||
{
|
||||
<div class="text-muted small" data-test="execution-tree-loading">Loading execution chain…</div>
|
||||
}
|
||||
else if (_error is not null)
|
||||
{
|
||||
<div class="alert alert-danger small" data-test="execution-tree-error">@_error</div>
|
||||
}
|
||||
else if (_nodes is { Count: > 0 })
|
||||
{
|
||||
<div class="mb-2">
|
||||
<a class="btn btn-outline-secondary btn-sm"
|
||||
data-test="execution-tree-back-to-log"
|
||||
href="@($"/audit/log?executionId={_executionId}")">
|
||||
View this execution in the Audit Log
|
||||
</a>
|
||||
</div>
|
||||
<ExecutionTree Nodes="_nodes" ArrivedFromExecutionId="_executionId.Value" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-secondary small" data-test="execution-tree-empty">
|
||||
No execution chain found for this id.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,93 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using ScadaLink.Commons.Types.Audit;
|
||||
|
||||
namespace ScadaLink.CentralUI.Components.Pages.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Code-behind for the execution-chain tree page (Audit Log ParentExecutionId
|
||||
/// feature, Task 10). Route <c>/audit/execution-tree</c>, reached via the Audit
|
||||
/// Log drilldown drawer's "View execution chain" action with
|
||||
/// <c>?executionId={guid}</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// On initialization the page parses <c>?executionId=</c> (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
|
||||
/// <see cref="ScadaLink.CentralUI.Services.IAuditLogQueryService.GetExecutionTreeAsync"/>
|
||||
/// for the whole chain. The flat <see cref="ExecutionTreeNode"/> list is handed
|
||||
/// to the recursive <c>ExecutionTree</c> component, which assembles + renders
|
||||
/// the tree.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The data path mirrors the Audit Log results grid: the page talks ONLY to the
|
||||
/// CentralUI <c>IAuditLogQueryService</c> facade, never <c>IAuditLogRepository</c>
|
||||
/// directly, so the page can be unit-tested with a substituted service.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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<ExecutionTreeNode>? _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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lax-parses <c>?executionId=</c>. Returns null when the param is absent or
|
||||
/// is not a valid <see cref="Guid"/> — the page then shows guidance instead
|
||||
/// of an error, consistent with the Audit Log page's drill-in handling.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,4 +132,23 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
|
||||
|
||||
return repoSnapshot with { BacklogTotal = backlog };
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<ExecutionTreeNode>> 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<IAuditLogRepository>();
|
||||
return await repository.GetExecutionTreeAsync(executionId, ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,4 +50,23 @@ public interface IAuditLogQueryService
|
||||
/// dashboard.
|
||||
/// </remarks>
|
||||
Task<AuditLogKpiSnapshot> GetKpiSnapshotAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log ParentExecutionId feature (Task 10) — returns the full
|
||||
/// execution chain containing <paramref name="executionId"/> as a flat list
|
||||
/// of <see cref="ExecutionTreeNode"/>, delegating to
|
||||
/// <see cref="ScadaLink.Commons.Interfaces.Repositories.IAuditLogRepository.GetExecutionTreeAsync"/>.
|
||||
/// The execution-chain tree view (<c>/audit/execution-tree</c>) assembles the
|
||||
/// returned flat list into a tree by joining
|
||||
/// <see cref="ExecutionTreeNode.ParentExecutionId"/> to a parent node's
|
||||
/// <see cref="ExecutionTreeNode.ExecutionId"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A pure pass-through, mirroring <see cref="QueryAsync"/> — the production
|
||||
/// implementation opens its own DI scope per call so the tree page's
|
||||
/// auto-load never contends with the circuit-scoped <c>ScadaLinkDbContext</c>.
|
||||
/// </remarks>
|
||||
Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||
Guid executionId,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,13 @@ public sealed record AuditEvent
|
||||
/// </summary>
|
||||
public Guid? ExecutionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ExecutionId"/> 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.
|
||||
/// </summary>
|
||||
public Guid? ParentExecutionId { get; init; }
|
||||
|
||||
/// <summary>Site id where the action originated; null for central-direct events.</summary>
|
||||
public string? SourceSiteId { get; init; }
|
||||
|
||||
|
||||
@@ -36,6 +36,15 @@ public class Notification
|
||||
/// submitted before the column existed, or raised outside a script-execution context.
|
||||
/// </summary>
|
||||
public Guid? OriginExecutionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The originating routed script execution's <c>ParentExecutionId</c> (Audit Log #23).
|
||||
/// Carried from the site on the <see cref="Commons.Messages.Notification.NotificationSubmit"/>
|
||||
/// so the central dispatcher can stamp the same parent id onto its <c>NotifyDeliver</c>
|
||||
/// audit rows, correlating them with the site-emitted <c>NotifySend</c> row. Null for
|
||||
/// non-routed runs, or for notifications submitted before the column existed.
|
||||
/// </summary>
|
||||
public Guid? OriginParentExecutionId { get; set; }
|
||||
public DateTimeOffset SiteEnqueuedAt { get; set; }
|
||||
|
||||
/// <summary>Central ingest time.</summary>
|
||||
|
||||
@@ -134,4 +134,45 @@ public interface IAuditLogRepository
|
||||
TimeSpan window,
|
||||
DateTime? nowUtc = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log ParentExecutionId feature (Task 8) — given any
|
||||
/// <paramref name="executionId"/> in an execution chain, returns the whole
|
||||
/// chain rooted at the topmost ancestor: one <see cref="ExecutionTreeNode"/>
|
||||
/// per distinct execution, summarising its <c>AuditLog</c> rows. The Central
|
||||
/// UI renders the result as a tree.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The input id may be any node in the chain — a leaf, the root, or a middle
|
||||
/// node. The implementation first walks <em>up</em> via
|
||||
/// <c>ParentExecutionId</c> to find the root, then walks <em>down</em> from
|
||||
/// the root via a recursive CTE, so the full chain is returned regardless of
|
||||
/// entry point.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The <c>ParentExecutionId</c> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A "stub" node — an execution that emitted no rows of its own yet is
|
||||
/// referenced by a child via <c>ParentExecutionId</c>, or whose rows have
|
||||
/// been purged — still appears, with <see cref="ExecutionTreeNode.RowCount"/>
|
||||
/// = 0. A purged/missing parent simply ends the upward walk.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When no <c>AuditLog</c> row carries <paramref name="executionId"/> in
|
||||
/// either <c>ExecutionId</c> or <c>ParentExecutionId</c>, the result is a
|
||||
/// single stub node for <paramref name="executionId"/> itself
|
||||
/// (<see cref="ExecutionTreeNode.RowCount"/> = 0) — consistent with the
|
||||
/// stub-node treatment of any other row-less execution.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||
Guid executionId,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -71,6 +71,16 @@ public interface ICachedCallLifecycleObserver
|
||||
/// rows carry the same <c>SourceScript</c> provenance the script-side cached
|
||||
/// rows already do. <c>null</c> when not known.
|
||||
/// </param>
|
||||
/// <param name="ParentExecutionId">
|
||||
/// Audit Log #23 (ParentExecutionId Task 6): the <c>ExecutionId</c> of the
|
||||
/// inbound-API request that spawned the originating script execution,
|
||||
/// threaded through the store-and-forward buffer alongside
|
||||
/// <paramref name="ExecutionId"/>. The audit bridge stamps it onto the
|
||||
/// retry-loop <c>ApiCallCached</c>/<c>DbWriteCached</c> Attempted and
|
||||
/// <c>CachedResolve</c> rows so they correlate back to the spawning run.
|
||||
/// <c>null</c> for a non-routed run and for rows buffered before Task 6
|
||||
/// (back-compat).
|
||||
/// </param>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Coarse outcome of one cached-call delivery attempt, observed from inside
|
||||
|
||||
@@ -40,6 +40,14 @@ public interface IDatabaseGateway
|
||||
/// threaded onto the buffered S&F message alongside
|
||||
/// <paramref name="executionId"/>. <c>null</c> when not known.
|
||||
/// </param>
|
||||
/// <param name="parentExecutionId">
|
||||
/// Audit Log #23 (ParentExecutionId Task 6): the <c>ExecutionId</c> 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 <paramref name="executionId"/> so the
|
||||
/// retry-loop cached-write audit rows carry it. <c>null</c> for a
|
||||
/// non-routed run.
|
||||
/// </param>
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,14 @@ public interface IExternalSystemClient
|
||||
/// threaded onto the buffered S&F message alongside
|
||||
/// <paramref name="executionId"/>. <c>null</c> when not known.
|
||||
/// </param>
|
||||
/// <param name="parentExecutionId">
|
||||
/// Audit Log #23 (ParentExecutionId Task 6): the <c>ExecutionId</c> 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 <paramref name="executionId"/> so the
|
||||
/// retry-loop cached-call audit rows carry it. <c>null</c> for a non-routed
|
||||
/// run.
|
||||
/// </param>
|
||||
Task<ExternalCallResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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).
|
||||
/// </summary>
|
||||
/// <param name="ParentExecutionId">
|
||||
/// Audit Log #23 (ParentExecutionId): the spawning execution's <c>ExecutionId</c>
|
||||
/// — 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
|
||||
/// <c>ParentExecutionId</c> 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).
|
||||
/// </param>
|
||||
public record RouteToCallRequest(
|
||||
string CorrelationId,
|
||||
string InstanceUniqueName,
|
||||
string ScriptName,
|
||||
IReadOnlyDictionary<string, object?>? Parameters,
|
||||
DateTimeOffset Timestamp);
|
||||
DateTimeOffset Timestamp,
|
||||
Guid? ParentExecutionId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Response from a Route.To() call.
|
||||
|
||||
@@ -11,6 +11,13 @@ namespace ScadaLink.Commons.Messages.Notification;
|
||||
/// <c>NotifyDeliver</c> audit rows. Additive trailing member — null for messages built
|
||||
/// before the field existed, or for notifications raised outside a script execution.
|
||||
/// </param>
|
||||
/// <param name="OriginParentExecutionId">
|
||||
/// The originating routed script execution's <c>ParentExecutionId</c> (Audit Log #23).
|
||||
/// Stamped at <c>Notify.Send</c> time and carried, inside the serialized payload, through
|
||||
/// the site store-and-forward buffer so the central dispatcher can echo it onto the
|
||||
/// <c>NotifyDeliver</c> audit rows. Additive trailing member — null for messages built
|
||||
/// before the field existed, or for non-routed runs.
|
||||
/// </param>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Central -> Site: ack sent after the notification row is persisted.
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
namespace ScadaLink.Commons.Messages.ScriptExecution;
|
||||
|
||||
/// <param name="ParentExecutionId">
|
||||
/// Audit Log #23 (ParentExecutionId): the spawning execution's <c>ExecutionId</c>.
|
||||
/// For an inbound-API-routed call this is the inbound request's per-request
|
||||
/// execution id (carried in from <c>RouteToCallRequest.ParentExecutionId</c>);
|
||||
/// the routed script execution records it as its <c>ParentExecutionId</c> so a
|
||||
/// spawned execution points back at its spawner. Additive trailing member —
|
||||
/// null for normal (tag-change / timer-triggered) runs, nested <c>Script.Call</c>
|
||||
/// invocations, and any request built before the field existed.
|
||||
/// </param>
|
||||
public record ScriptCallRequest(
|
||||
string ScriptName,
|
||||
IReadOnlyDictionary<string, object?>? Parameters,
|
||||
int CurrentCallDepth,
|
||||
string CorrelationId);
|
||||
string CorrelationId,
|
||||
Guid? ParentExecutionId = null);
|
||||
|
||||
@@ -12,8 +12,8 @@ namespace ScadaLink.Commons.Types.Audit;
|
||||
/// the spec sense — <see cref="FromUtc"/> is inclusive and <see cref="ToUtc"/> is
|
||||
/// inclusive of the upper bound; the repository SQL uses <c>>=</c> / <c><=</c>
|
||||
/// respectively. All filter dimensions are AND-combined with one another. The
|
||||
/// single-value <see cref="CorrelationId"/> and <see cref="ExecutionId"/>
|
||||
/// dimensions constrain on equality when set.
|
||||
/// single-value <see cref="CorrelationId"/>, <see cref="ExecutionId"/> and
|
||||
/// <see cref="ParentExecutionId"/> dimensions constrain on equality when set.
|
||||
/// </summary>
|
||||
public sealed record AuditLogQueryFilter(
|
||||
IReadOnlyList<AuditChannel>? 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);
|
||||
|
||||
71
src/ScadaLink.Commons/Types/Audit/ExecutionTreeNode.cs
Normal file
71
src/ScadaLink.Commons/Types/Audit/ExecutionTreeNode.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
namespace ScadaLink.Commons.Types.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// One execution within an execution chain returned by
|
||||
/// <see cref="ScadaLink.Commons.Interfaces.Repositories.IAuditLogRepository.GetExecutionTreeAsync"/>.
|
||||
/// Each node summarises the <c>AuditLog</c> rows sharing a single
|
||||
/// <see cref="ExecutionId"/>; the Central UI renders the set as a tree by
|
||||
/// joining <see cref="ParentExecutionId"/> to a parent node's
|
||||
/// <see cref="ExecutionId"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Stub nodes.</b> 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
|
||||
/// <see cref="ParentExecutionId"/>. Such a stub node has <see cref="RowCount"/>
|
||||
/// = 0, empty <see cref="Channels"/>/<see cref="Statuses"/>, null
|
||||
/// <see cref="SourceSiteId"/>/<see cref="SourceInstanceId"/>, null timestamps,
|
||||
/// and a null <see cref="ParentExecutionId"/> (a purged/ghost parent leaves no
|
||||
/// row from which its own parent could be read — the upward walk ends there).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="Channels"/> and <see cref="Statuses"/> are the distinct sets of
|
||||
/// the corresponding enum names present across the execution's rows, modelled
|
||||
/// as <see cref="IReadOnlyList{T}"/> of string to mirror how the repository's
|
||||
/// query filters already pass small bounded sets around.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="ExecutionId">The execution this node summarises.</param>
|
||||
/// <param name="ParentExecutionId">
|
||||
/// The <see cref="ExecutionId"/> of the spawning execution, or null for the
|
||||
/// root (and for stub nodes, whose own parent is unknowable).
|
||||
/// </param>
|
||||
/// <param name="RowCount">
|
||||
/// Number of <c>AuditLog</c> rows carrying this <see cref="ExecutionId"/>; 0 for
|
||||
/// a stub node.
|
||||
/// </param>
|
||||
/// <param name="Channels">
|
||||
/// Distinct <see cref="ScadaLink.Commons.Types.Enums.AuditChannel"/> names
|
||||
/// present across this execution's rows; empty for a stub node.
|
||||
/// </param>
|
||||
/// <param name="Statuses">
|
||||
/// Distinct <see cref="ScadaLink.Commons.Types.Enums.AuditStatus"/> names
|
||||
/// present across this execution's rows; empty for a stub node.
|
||||
/// </param>
|
||||
/// <param name="SourceSiteId">
|
||||
/// Source site of the execution's rows when consistent; null for a stub node
|
||||
/// (or when the rows carry no site).
|
||||
/// </param>
|
||||
/// <param name="SourceInstanceId">
|
||||
/// Source instance of the execution's rows when consistent; null for a stub
|
||||
/// node (or when the rows carry no instance).
|
||||
/// </param>
|
||||
/// <param name="FirstOccurredAtUtc">
|
||||
/// Earliest <c>OccurredAtUtc</c> across this execution's rows; null for a stub
|
||||
/// node.
|
||||
/// </param>
|
||||
/// <param name="LastOccurredAtUtc">
|
||||
/// Latest <c>OccurredAtUtc</c> across this execution's rows; null for a stub
|
||||
/// node.
|
||||
/// </param>
|
||||
public sealed record ExecutionTreeNode(
|
||||
Guid ExecutionId,
|
||||
Guid? ParentExecutionId,
|
||||
int RowCount,
|
||||
IReadOnlyList<string> Channels,
|
||||
IReadOnlyList<string> Statuses,
|
||||
string? SourceSiteId,
|
||||
string? SourceInstanceId,
|
||||
DateTime? FirstOccurredAtUtc,
|
||||
DateTime? LastOccurredAtUtc);
|
||||
@@ -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<AuditKind>(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),
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "parent_execution_id" field.</summary>
|
||||
public const int ParentExecutionIdFieldNumber = 21;
|
||||
private string parentExecutionId_ = "";
|
||||
/// <summary>
|
||||
/// empty string represents null
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,10 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditEve
|
||||
.HasFilter("[ExecutionId] IS NOT NULL")
|
||||
.HasDatabaseName("IX_AuditLog_Execution");
|
||||
|
||||
builder.HasIndex(e => 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");
|
||||
|
||||
@@ -51,6 +51,10 @@ public class NotificationOutboxConfiguration : IEntityTypeConfiguration<Notifica
|
||||
// site so the dispatcher can echo it onto NotifyDeliver audit rows. No index —
|
||||
// it is never a query predicate on this table, only copied onto audit events.
|
||||
|
||||
// OriginParentExecutionId (Audit Log #23): nullable uniqueidentifier carried from
|
||||
// the site — the routed run's parent ExecutionId — so the dispatcher can echo it
|
||||
// onto NotifyDeliver audit rows. No index — same rationale as OriginExecutionId.
|
||||
|
||||
builder.HasIndex(n => new { n.Status, n.NextAttemptAt });
|
||||
|
||||
builder.HasIndex(n => new { n.SourceSiteId, n.CreatedAt });
|
||||
|
||||
1636
src/ScadaLink.ConfigurationDatabase/Migrations/20260521210254_AddAuditLogParentExecutionId.Designer.cs
generated
Normal file
1636
src/ScadaLink.ConfigurationDatabase/Migrations/20260521210254_AddAuditLogParentExecutionId.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the <c>ParentExecutionId</c> correlation column to the centralized
|
||||
/// <c>AuditLog</c> table (#23). <c>ParentExecutionId</c> carries the
|
||||
/// <c>ExecutionId</c> of the execution that spawned this run, letting a
|
||||
/// spawned execution point back at its spawner — a sibling to the universal
|
||||
/// per-run <c>ExecutionId</c>.
|
||||
///
|
||||
/// The change is purely additive:
|
||||
/// 1. <c>ParentExecutionId uniqueidentifier NULL</c> is added with no default,
|
||||
/// so the operation is a metadata-only <c>ALTER TABLE … ADD</c> — it does
|
||||
/// NOT rewrite the monthly-partitioned <c>AuditLog</c> table, and
|
||||
/// historical rows stay <c>NULL</c> (no backfill).
|
||||
/// 2. <c>IX_AuditLog_ParentExecution</c> is created via raw SQL so it lands on
|
||||
/// the <c>ps_AuditLog_Month(OccurredAtUtc)</c> partition scheme, matching
|
||||
/// every other <c>IX_AuditLog_*</c> index. Keeping it partition-aligned
|
||||
/// preserves the partition-switch purge path (see
|
||||
/// AuditLogRepository.SwitchOutPartitionAsync).
|
||||
/// </summary>
|
||||
public partial class AddAuditLogParentExecutionId : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
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);");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the <c>OriginParentExecutionId</c> correlation column to the central
|
||||
/// <c>Notifications</c> table (#21). It carries the originating routed script
|
||||
/// execution's <c>ParentExecutionId</c> from the site so the dispatcher can echo it
|
||||
/// onto the <c>NotifyDeliver</c> audit rows (#23), linking them to the routed run's
|
||||
/// parent. Sibling of <c>OriginExecutionId</c>.
|
||||
///
|
||||
/// The change is purely additive: <c>OriginParentExecutionId uniqueidentifier NULL</c>
|
||||
/// is added with no default, so the operation is a metadata-only
|
||||
/// <c>ALTER TABLE … ADD</c>. Unlike <c>AuditLog</c>, the <c>Notifications</c> table is
|
||||
/// NOT partitioned, so a plain <c>ADD</c> is fine. No index is created — the column is
|
||||
/// never a query predicate, only copied onto audit events. Historical rows stay
|
||||
/// <c>NULL</c>.
|
||||
/// </summary>
|
||||
public partial class AddNotificationOriginParentExecutionId : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "OriginParentExecutionId",
|
||||
table: "Notifications",
|
||||
type: "uniqueidentifier",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "OriginParentExecutionId",
|
||||
table: "Notifications");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,9 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||
.IsUnicode(false)
|
||||
.HasColumnType("varchar(32)");
|
||||
|
||||
b.Property<Guid?>("ParentExecutionId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<bool>("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");
|
||||
@@ -790,6 +797,9 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||
b.Property<Guid?>("OriginExecutionId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid?>("OriginParentExecutionId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("ResolvedTargets")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -547,4 +555,227 @@ 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;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log ParentExecutionId (Task 8) — returns the whole execution chain
|
||||
/// containing <paramref name="executionId"/>, regardless of entry point.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two phases. <b>Walk up:</b> an iterative
|
||||
/// <c>SELECT TOP 1 ParentExecutionId … WHERE ExecutionId = @cur AND ParentExecutionId IS NOT NULL</c>
|
||||
/// climbs from the supplied node to the root — the last execution id with no
|
||||
/// parent. The loop is capped at <see cref="ExecutionChainMaxDepth"/>
|
||||
/// iterations; a purged/missing parent simply ends the climb early. <b>Walk
|
||||
/// down:</b> a recursive CTE over a DISTINCT
|
||||
/// <c>(ExecutionId, ParentExecutionId)</c> edge set, seeded at the root edge
|
||||
/// and joining <c>edge.ParentExecutionId = chain.ExecutionId</c> to
|
||||
/// enumerate every descendant. Recursing over edges rather than raw rows
|
||||
/// keeps the recursion one path wide per execution. It is bounded by
|
||||
/// <c>OPTION (MAXRECURSION ...)</c> at <see cref="ExecutionChainMaxDepth"/>
|
||||
/// — corrupt cyclic data raises a <see cref="SqlException"/> (msg 530)
|
||||
/// rather than spinning.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The chain's full execution-id set is every edge's <c>ExecutionId</c>
|
||||
/// unioned with its non-null <c>ParentExecutionId</c>, 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 <c>AuditLog</c> and
|
||||
/// <c>GROUP BY</c>s, so a stub yields a node with <c>RowCount = 0</c> and
|
||||
/// empty/null aggregates. The query is SELECT-only
|
||||
/// (the audit writer role grants no UPDATE/DELETE — reads are unrestricted).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task<IReadOnlyList<ExecutionTreeNode>> 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 ---------
|
||||
// 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<ExecutionTreeNode>();
|
||||
await using (var downCmd = conn.CreateCommand())
|
||||
{
|
||||
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 e.ExecutionId, e.ParentExecutionId
|
||||
FROM Edges e
|
||||
INNER JOIN Chain c ON e.ParentExecutionId = c.ExecutionId
|
||||
),
|
||||
ChainIds AS (
|
||||
SELECT ExecutionId FROM Chain
|
||||
UNION
|
||||
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],
|
||||
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 {ExecutionChainMaxDepth});";
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits a <c>STRING_AGG</c> 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.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<string> SplitAggregate(string? aggregate)
|
||||
{
|
||||
if (string.IsNullOrEmpty(aggregate))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return aggregate
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(v => v, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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,25 @@ public class InboundScriptExecutor
|
||||
/// <summary>
|
||||
/// Executes the script for the given method with the provided context.
|
||||
/// </summary>
|
||||
/// <param name="parentExecutionId">
|
||||
/// Audit Log #23 (ParentExecutionId): the inbound API request's per-request
|
||||
/// <c>ExecutionId</c> (minted early by <c>AuditWriteMiddleware</c> and stashed
|
||||
/// on <c>HttpContext.Items</c>). When supplied, a routed
|
||||
/// <c>Route.To(...).Call(...)</c> inside the script carries it as
|
||||
/// <see cref="RouteToCallRequest.ParentExecutionId"/> so the spawned site
|
||||
/// script execution points back at this inbound request. Null when the script
|
||||
/// runs outside an inbound API request flow.
|
||||
/// </param>
|
||||
public async Task<InboundScriptResult> ExecuteAsync(
|
||||
ApiMethod method,
|
||||
IReadOnlyDictionary<string, object?> parameters,
|
||||
RouteHelper route,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken = default)
|
||||
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
|
||||
// separable. A single linked CTS makes a genuine client disconnect
|
||||
@@ -177,7 +191,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))
|
||||
{
|
||||
|
||||
@@ -59,6 +59,18 @@ public sealed class AuditWriteMiddleware
|
||||
/// </summary>
|
||||
public const string AuditActorItemKey = "ScadaLink.InboundAPI.AuditActor";
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ParentExecutionId): <see cref="HttpContext.Items"/> key under
|
||||
/// which this middleware stashes the inbound request's per-request
|
||||
/// <c>ExecutionId</c> (a <see cref="Guid"/>) 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 <c>RouteToCallRequest.ParentExecutionId</c>, and the
|
||||
/// middleware's own inbound audit row uses the same id for its
|
||||
/// <see cref="AuditEvent.ExecutionId"/>. Exposed as a constant so the handler
|
||||
/// and middleware share a single source of truth (no stringly-typed coupling).
|
||||
/// </summary>
|
||||
public const string InboundExecutionIdItemKey = "ScadaLink.InboundAPI.InboundExecutionId";
|
||||
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ICentralAuditWriter _auditWriter;
|
||||
private readonly ILogger<AuditWriteMiddleware> _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,29 @@ public sealed class AuditWriteMiddleware
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ParentExecutionId): reads the inbound request's per-request
|
||||
/// <c>ExecutionId</c> that <see cref="InvokeAsync"/> minted and stashed on
|
||||
/// <see cref="HttpContext.Items"/> under <see cref="InboundExecutionIdItemKey"/>.
|
||||
/// Throws <see cref="InvalidOperationException"/> 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. <see cref="EmitInboundAudit"/>'s
|
||||
/// try/catch degrades the throw to a dropped best-effort audit row, never a
|
||||
/// failed request.
|
||||
/// </summary>
|
||||
private static Guid ResolveInboundExecutionId(HttpContext ctx)
|
||||
{
|
||||
if (ctx.Items.TryGetValue(InboundExecutionIdItemKey, out var stashed)
|
||||
&& stashed is Guid id)
|
||||
{
|
||||
return id;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"Inbound ExecutionId invariant violated: the inbound ExecutionId must be "
|
||||
+ "stashed by AuditWriteMiddleware.InvokeAsync before the audit row is emitted.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the API key name the endpoint handler stashed on
|
||||
/// <see cref="HttpContext.Items"/> after successful auth. Falls back to
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -45,14 +48,27 @@ public class RouteHelper
|
||||
/// requires.
|
||||
/// </summary>
|
||||
public RouteHelper WithDeadline(CancellationToken deadlineToken) =>
|
||||
new(_instanceLocator, _instanceRouter, deadlineToken);
|
||||
new(_instanceLocator, _instanceRouter, deadlineToken, _parentExecutionId);
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ParentExecutionId): returns a <see cref="RouteHelper"/> whose
|
||||
/// routed <see cref="RouteTarget.Call"/> requests carry
|
||||
/// <paramref name="parentExecutionId"/> as <see cref="RouteToCallRequest.ParentExecutionId"/>.
|
||||
/// 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. <see cref="InboundScriptExecutor"/> calls this when it builds the
|
||||
/// script context.
|
||||
/// </summary>
|
||||
public RouteHelper WithParentExecutionId(Guid? parentExecutionId) =>
|
||||
new(_instanceLocator, _instanceRouter, _deadlineToken, parentExecutionId);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a route target for the specified instance.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -492,7 +492,8 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
|
||||
/// <see cref="AuditEvent.ExecutionId"/> is copied straight from
|
||||
/// <see cref="Notification.OriginExecutionId"/> so the dispatcher's
|
||||
/// <c>NotifyDeliver</c> rows carry the same per-run id as the site's
|
||||
/// <c>NotifySend</c> row (Audit Log #23).
|
||||
/// <c>NotifySend</c> row (Audit Log #23); <see cref="AuditEvent.ParentExecutionId"/>
|
||||
/// is likewise copied from <see cref="Notification.OriginParentExecutionId"/>.
|
||||
/// </summary>
|
||||
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.
|
||||
|
||||
@@ -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<ScriptCallResult>(scriptCall, TimeSpan.FromSeconds(30))
|
||||
.ContinueWith(t =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -379,7 +385,8 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
||||
IReadOnlyDictionary<string, object?>? 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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -38,12 +38,22 @@ internal sealed class AuditingDbCommand : DbCommand
|
||||
private readonly string _instanceName;
|
||||
private readonly string? _sourceScript;
|
||||
private readonly Guid _executionId;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ParentExecutionId): the spawning execution's id when this
|
||||
/// run was inbound-API-routed; <c>null</c> for non-routed runs. Threaded
|
||||
/// alongside <see cref="_executionId"/> and stamped onto the <c>DbWrite</c>
|
||||
/// audit row.
|
||||
/// </summary>
|
||||
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,
|
||||
|
||||
@@ -37,11 +37,21 @@ internal sealed class AuditingDbConnection : DbConnection
|
||||
private readonly string _instanceName;
|
||||
private readonly string? _sourceScript;
|
||||
private readonly Guid _executionId;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ParentExecutionId): the spawning execution's id when this
|
||||
/// run was inbound-API-routed; <c>null</c> for non-routed runs. Threaded
|
||||
/// alongside <see cref="_executionId"/> into the
|
||||
/// <see cref="AuditingDbCommand"/> so its <c>DbWrite</c> row stamps it.
|
||||
/// </summary>
|
||||
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)
|
||||
|
||||
@@ -116,6 +116,19 @@ public class ScriptRuntimeContext
|
||||
/// </summary>
|
||||
private readonly Guid _executionId;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ParentExecutionId): the spawning execution's
|
||||
/// <see cref="_executionId"/> 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. <c>null</c> for normal (tag-change /
|
||||
/// timer-triggered) runs and nested <c>CallScript</c> invocations. The
|
||||
/// routed script still mints its OWN fresh <see cref="_executionId"/>; 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 <c>AuditEvent.ParentExecutionId</c>.)
|
||||
/// </summary>
|
||||
private readonly Guid? _parentExecutionId;
|
||||
|
||||
/// <param name="executionId">
|
||||
/// Audit Log #23: the per-execution id for this script run. When omitted
|
||||
/// (tag-change / timer-triggered executions) a fresh id is generated; an
|
||||
@@ -123,6 +136,13 @@ public class ScriptRuntimeContext
|
||||
/// request. Stamped into <c>AuditEvent.ExecutionId</c> on every
|
||||
/// trust-boundary audit row this execution emits.
|
||||
/// </param>
|
||||
/// <param name="parentExecutionId">
|
||||
/// Audit Log #23 (ParentExecutionId): the spawning execution's
|
||||
/// <c>ExecutionId</c> — supplied for an inbound-API-routed call (the
|
||||
/// inbound request's per-request id), <c>null</c> for normal (tag-change /
|
||||
/// timer-triggered) runs. The routed script still generates its own fresh
|
||||
/// <paramref name="executionId"/>; this only records the spawner.
|
||||
/// </param>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// 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);
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to the Notification Outbox API.
|
||||
@@ -302,7 +332,10 @@ public class ScriptRuntimeContext
|
||||
/// </remarks>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// 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;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ParentExecutionId): the spawning execution's id when
|
||||
/// this run was inbound-API-routed; <c>null</c> for non-routed runs.
|
||||
/// Threaded alongside <see cref="_executionId"/> ready for the Task 5
|
||||
/// emitter — no audit row carries it yet.
|
||||
/// </summary>
|
||||
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<ExternalCallResult> Call(
|
||||
@@ -518,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)
|
||||
{
|
||||
@@ -580,6 +631,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,
|
||||
@@ -693,6 +747,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,
|
||||
@@ -757,6 +814,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,
|
||||
@@ -934,6 +994,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,
|
||||
@@ -1001,6 +1064,15 @@ public class ScriptRuntimeContext
|
||||
private readonly string _instanceName;
|
||||
private readonly ILogger _logger;
|
||||
private readonly Guid _executionId;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ParentExecutionId): the spawning execution's id when
|
||||
/// this run was inbound-API-routed; <c>null</c> for non-routed runs.
|
||||
/// Threaded alongside <see cref="_executionId"/> ready for the Task 5
|
||||
/// emitter — no audit row carries it yet.
|
||||
/// </summary>
|
||||
private readonly Guid? _parentExecutionId;
|
||||
|
||||
private readonly string _siteId;
|
||||
private readonly string? _sourceScript;
|
||||
private readonly ICachedCallTelemetryForwarder? _cachedForwarder;
|
||||
@@ -1020,7 +1092,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 +1101,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 +1112,7 @@ public class ScriptRuntimeContext
|
||||
_siteId = siteId;
|
||||
_sourceScript = sourceScript;
|
||||
_cachedForwarder = cachedForwarder;
|
||||
_parentExecutionId = parentExecutionId;
|
||||
}
|
||||
|
||||
public async Task<System.Data.Common.DbConnection> Connection(
|
||||
@@ -1070,7 +1144,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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1106,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)
|
||||
@@ -1146,6 +1228,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,
|
||||
@@ -1213,6 +1298,14 @@ public class ScriptRuntimeContext
|
||||
/// </summary>
|
||||
private readonly Guid _executionId;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ParentExecutionId): the spawning execution's id when
|
||||
/// this run was inbound-API-routed; <c>null</c> for non-routed runs.
|
||||
/// Threaded alongside <see cref="_executionId"/> ready for the Task 5
|
||||
/// emitter — no audit row carries it yet.
|
||||
/// </summary>
|
||||
private readonly Guid? _parentExecutionId;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (M4 Bundle C): best-effort emitter for the
|
||||
/// <c>Notification</c>/<c>NotifySend</c> row produced when the script
|
||||
@@ -1224,7 +1317,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 +1328,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 +1340,7 @@ public class ScriptRuntimeContext
|
||||
_logger = logger;
|
||||
_executionId = executionId;
|
||||
_auditWriter = auditWriter;
|
||||
_parentExecutionId = parentExecutionId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1259,7 +1355,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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1340,6 +1439,14 @@ public class ScriptRuntimeContext
|
||||
/// </summary>
|
||||
private readonly Guid _executionId;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ParentExecutionId): the spawning execution's id when
|
||||
/// this run was inbound-API-routed; <c>null</c> for non-routed runs.
|
||||
/// Threaded alongside <see cref="_executionId"/> ready for the Task 5
|
||||
/// emitter — no audit row carries it yet.
|
||||
/// </summary>
|
||||
private readonly Guid? _parentExecutionId;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (M4 Bundle C): best-effort emitter for the
|
||||
/// <c>Notification</c>/<c>NotifySend</c> row written immediately after
|
||||
@@ -1356,7 +1463,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 +1474,7 @@ public class ScriptRuntimeContext
|
||||
_logger = logger;
|
||||
_executionId = executionId;
|
||||
_auditWriter = auditWriter;
|
||||
_parentExecutionId = parentExecutionId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1412,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);
|
||||
|
||||
@@ -1490,6 +1605,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,
|
||||
|
||||
@@ -76,4 +76,19 @@ public class StoreAndForwardMessage
|
||||
/// known (non-cached categories, pre-migration rows).
|
||||
/// </summary>
|
||||
public string? SourceScript { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ParentExecutionId Task 6): the <c>ExecutionId</c> of the
|
||||
/// inbound-API request that spawned the originating script execution,
|
||||
/// threaded alongside <see cref="ExecutionId"/> 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
|
||||
/// (<c>ApiCallCached</c>/<c>DbWriteCached</c> Attempted, <c>CachedResolve</c>),
|
||||
/// keeping them correlated with the cross-execution chain. <c>null</c> 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).
|
||||
/// </summary>
|
||||
public Guid? ParentExecutionId { get; set; }
|
||||
}
|
||||
|
||||
@@ -187,6 +187,14 @@ public class StoreAndForwardService
|
||||
/// so the retry-loop audit rows carry the same provenance the script-side
|
||||
/// cached rows do. <c>null</c> when not known.
|
||||
/// </param>
|
||||
/// <param name="parentExecutionId">
|
||||
/// Audit Log #23 (ParentExecutionId Task 6): the <c>ExecutionId</c> of the
|
||||
/// inbound-API request that spawned the originating script execution.
|
||||
/// Threaded onto the buffered row alongside <paramref name="executionId"/>
|
||||
/// so the retry-loop cached-call audit rows carry it. <c>null</c> for a
|
||||
/// non-routed run and for callers (notifications, pre-Task-6 callers) that
|
||||
/// do not supply one.
|
||||
/// </param>
|
||||
public async Task<StoreAndForwardResult> 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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 (ExecutionId Task 4): defensively reads the
|
||||
/// <c>execution_id</c> column. A <c>null</c> value (legacy pre-migration
|
||||
/// Audit Log #23 (ExecutionId Task 4 / ParentExecutionId Task 6):
|
||||
/// defensively reads a nullable GUID column (<c>execution_id</c> or
|
||||
/// <c>parent_execution_id</c>). A <c>null</c> value (legacy pre-migration
|
||||
/// rows) and a malformed non-null value both yield <c>null</c> — a corrupt
|
||||
/// id must not throw and abort the retry sweep, which reads many rows.
|
||||
/// </summary>
|
||||
private static Guid? ParseExecutionId(System.Data.Common.DbDataReader reader, int ordinal)
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,5 +224,9 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture<MsSqlMigrationFix
|
||||
public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
||||
_inner.GetKpiSnapshotAsync(window, nowUtc, ct);
|
||||
|
||||
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||
Guid executionId, CancellationToken ct = default) =>
|
||||
_inner.GetExecutionTreeAsync(executionId, ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,10 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture<MsSqlMigrationFixt
|
||||
public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
||||
Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
|
||||
|
||||
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||
Guid executionId, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
|
||||
}
|
||||
|
||||
private IServiceProvider BuildScopedProvider(IAuditLogRepository repo)
|
||||
|
||||
@@ -51,6 +51,10 @@ public class CentralAuditWriteFailuresTests : TestKit
|
||||
public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
||||
Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
|
||||
|
||||
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||
Guid executionId, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -97,6 +97,10 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture<MsSqlMig
|
||||
public Task<ScadaLink.Commons.Types.AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) =>
|
||||
Task.FromResult(new ScadaLink.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow));
|
||||
|
||||
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||
Guid executionId, CancellationToken ct = default) =>
|
||||
Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,618 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log #23 — <b>ParentExecutionId cross-execution correlation</b> headline
|
||||
/// end-to-end suite. Verifies the inbound-API → routed-site-script bridge: an
|
||||
/// inbound HTTP request runs an inbound method script that calls
|
||||
/// <c>Route.Call</c> into a site instance; the routed site script does a sync
|
||||
/// <c>ExternalSystem.Call</c>, a cached call and a <c>Notify.Send</c>. Every
|
||||
/// audit row the routed run produces — site + central, sync + cached lifecycle
|
||||
/// + <c>NotifySend</c>/<c>NotifyDeliver</c> — must carry
|
||||
/// <see cref="AuditEvent.ParentExecutionId"/> equal to the inbound request's
|
||||
/// <see cref="AuditEvent.ExecutionId"/>, while the routed run has its own
|
||||
/// distinct <see cref="AuditEvent.ExecutionId"/> and the inbound
|
||||
/// <see cref="AuditKind.InboundRequest"/> row is top-level
|
||||
/// (<c>ParentExecutionId = NULL</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the integration-level counterpart to <see cref="ExecutionIdCorrelationTests"/>:
|
||||
/// where that suite drives a single <see cref="ScriptRuntimeContext"/> run and
|
||||
/// asserts the shared per-run <c>ExecutionId</c>, this suite spans <b>two</b>
|
||||
/// executions on opposite sides of the inbound→routed bridge and asserts the
|
||||
/// cross-execution <c>ParentExecutionId</c> linkage plus
|
||||
/// <see cref="IAuditLogRepository.GetExecutionTreeAsync"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The bridge is exercised through the genuine production glue:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>the real <see cref="AuditWriteMiddleware"/> in a
|
||||
/// Microsoft.AspNetCore.TestHost pipeline — mints the inbound request's
|
||||
/// per-request <c>ExecutionId</c> once, stashes it on
|
||||
/// <see cref="HttpContext.Items"/>, and emits the top-level
|
||||
/// <see cref="AuditKind.InboundRequest"/> row via the real
|
||||
/// <see cref="CentralAuditWriter"/>;</description></item>
|
||||
/// <item><description>the real <see cref="InboundScriptExecutor"/> +
|
||||
/// <see cref="RouteHelper"/> — the executor binds the stashed inbound
|
||||
/// <c>ExecutionId</c> via <see cref="RouteHelper.WithParentExecutionId"/>, so a
|
||||
/// <c>Route.To(...).Call(...)</c> inside the inbound script builds a
|
||||
/// <see cref="RouteToCallRequest"/> carrying
|
||||
/// <see cref="RouteToCallRequest.ParentExecutionId"/>.</description></item>
|
||||
/// </list>
|
||||
/// Only the cross-cluster routing transport is substituted: the test
|
||||
/// <see cref="BridgingInstanceRouter"/> stands in for
|
||||
/// <c>CommunicationServiceInstanceRouter</c> exactly as the production site
|
||||
/// (<c>DeploymentManagerActor</c> → <c>ScriptActor</c> → <c>ScriptExecutionActor</c>)
|
||||
/// would — it reads <see cref="RouteToCallRequest.ParentExecutionId"/> off the
|
||||
/// wire request and threads it into the routed <see cref="ScriptRuntimeContext"/>
|
||||
/// as <c>parentExecutionId</c>. A multi-node cluster is out of scope for an
|
||||
/// in-process test (mirroring <c>SiteAuditPushFlowTests</c>'s relay).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The central audit store is the real <see cref="AuditLogRepository"/> over the
|
||||
/// per-class <see cref="MsSqlMigrationFixture"/> MSSQL database; the routed run's
|
||||
/// site rows reach it through the real <see cref="SqliteAuditWriter"/> hot-path +
|
||||
/// <see cref="SiteAuditTelemetryActor"/> drain, the cached lifecycle rows through
|
||||
/// the production <see cref="CachedCallTelemetryForwarder"/>, and the
|
||||
/// <c>NotifyDeliver</c> rows through the real central
|
||||
/// <see cref="NotificationOutboxActor"/> dispatcher.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class ParentExecutionIdCorrelationTests : TestKit, IClassFixture<MsSqlMigrationFixture>
|
||||
{
|
||||
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";
|
||||
|
||||
/// <summary>Per-run site id (Guid suffix) so concurrent tests sharing the MSSQL fixture stay isolated.</summary>
|
||||
private static string NewSiteId() =>
|
||||
"test-parentexec-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||
|
||||
private ScadaLinkDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
||||
.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<ScadaLinkDbContext>(opts =>
|
||||
opts.UseSqlServer(_fixture.ConnectionString)
|
||||
.ConfigureWarnings(w => w.Ignore(
|
||||
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
|
||||
centralServices.AddScoped<IAuditLogRepository>(sp =>
|
||||
new AuditLogRepository(sp.GetRequiredService<ScadaLinkDbContext>()));
|
||||
centralServices.AddScoped<ISiteCallAuditRepository>(sp =>
|
||||
new SiteCallAuditRepository(sp.GetRequiredService<ScadaLinkDbContext>()));
|
||||
centralServices.AddScoped<INotificationOutboxRepository>(sp =>
|
||||
new NotificationOutboxRepository(sp.GetRequiredService<ScadaLinkDbContext>()));
|
||||
centralServices.AddScoped<INotificationRepository>(sp =>
|
||||
new NotificationRepository(sp.GetRequiredService<ScadaLinkDbContext>()));
|
||||
// The NotifyDeliver dispatch path runs through this same long-lived
|
||||
// provider — a stub adapter that always reports a successful delivery.
|
||||
centralServices.AddScoped<INotificationDeliveryAdapter>(_ => new AlwaysDeliversAdapter());
|
||||
await using var centralProvider = centralServices.BuildServiceProvider();
|
||||
|
||||
var ingestActor = Sys.ActorOf(Props.Create(() => new AuditLogIngestActor(
|
||||
(IServiceProvider)centralProvider,
|
||||
NullLogger<AuditLogIngestActor>.Instance)));
|
||||
var centralAuditWriter = new CentralAuditWriter(
|
||||
centralProvider, NullLogger<CentralAuditWriter>.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<SqliteAuditWriter>.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<FallbackAuditWriter>.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<SiteAuditTelemetryActor>.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<CachedCallTelemetryForwarder>.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<StoreAndForwardStorage>.Instance);
|
||||
await safStorage.InitializeAsync();
|
||||
var storeAndForward = new StoreAndForwardService(
|
||||
safStorage,
|
||||
new StoreAndForwardOptions
|
||||
{
|
||||
DefaultRetryInterval = TimeSpan.Zero,
|
||||
DefaultMaxRetries = 3,
|
||||
RetryTimerInterval = TimeSpan.FromMinutes(10),
|
||||
},
|
||||
NullLogger<StoreAndForwardService>.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<IExternalSystemClient>();
|
||||
externalClient
|
||||
.CallAsync(ExternalSystemName, ExternalMethodName,
|
||||
Arg.Any<IReadOnlyDictionary<string, object?>?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new ExternalCallResult(true, "{\"ok\":true}", null));
|
||||
externalClient
|
||||
.CachedCallAsync(ExternalSystemName, ExternalMethodName,
|
||||
Arg.Any<IReadOnlyDictionary<string, object?>?>(),
|
||||
Arg.Any<string?>(), Arg.Any<CancellationToken>(),
|
||||
Arg.Any<ScadaLink.Commons.Types.TrackedOperationId?>(),
|
||||
Arg.Any<Guid?>(), Arg.Any<string?>(), Arg.Any<Guid?>())
|
||||
.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<IInstanceLocator>();
|
||||
locator.GetSiteIdForInstanceAsync(RoutedInstanceCode, Arg.Any<CancellationToken>())
|
||||
.Returns(siteId);
|
||||
var scriptExecutor = new InboundScriptExecutor(
|
||||
NullLogger<InboundScriptExecutor>.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<string, object?>(),
|
||||
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 emits its sync-ApiCall and NotifySend audit rows on a
|
||||
// deliberately fire-and-forget path (alog.md §7 — an audit write must
|
||||
// never block the user-facing script call). `Notify.Send` therefore
|
||||
// returns — and the routed `RouteToCallAsync` completes — BEFORE the
|
||||
// SqliteAuditWriter background loop has flushed the NotifySend row into
|
||||
// the site hot-path. Wait for all five site rows to be durably present
|
||||
// in SQLite before the central assertion: this is the production
|
||||
// durability point (the row IS in SQLite before it is considered
|
||||
// audited), and pinning it removes the emit-vs-drain race that
|
||||
// otherwise let the SiteAuditTelemetryADrain forward only four rows on
|
||||
// its first tick and leave NotifySend stranded for a full drain
|
||||
// interval under heavy parallel load.
|
||||
await WaitForSiteRowsPersistedAsync(sqliteWriter);
|
||||
|
||||
// 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(90));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts the execution tree is the expected two-node inbound→routed chain:
|
||||
/// the inbound execution is the root (<c>ParentExecutionId = NULL</c>) and the
|
||||
/// routed execution's <c>ParentExecutionId</c> points back at it.
|
||||
/// </summary>
|
||||
private static void AssertChain(
|
||||
IReadOnlyList<ExecutionTreeNode> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spins up a minimal in-memory ASP.NET host whose pipeline mirrors the
|
||||
/// production inbound-API arrangement: routing → the real
|
||||
/// <see cref="AuditWriteMiddleware"/> → the <c>POST /api/{methodName}</c>
|
||||
/// endpoint. The middleware mints + stashes the inbound request's
|
||||
/// <c>ExecutionId</c> and emits the top-level <see cref="AuditKind.InboundRequest"/>
|
||||
/// row via the supplied <see cref="ICentralAuditWriter"/>.
|
||||
/// </summary>
|
||||
private static async Task<IHost> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the genuine site-produced <see cref="NotificationSubmit"/> the routed
|
||||
/// <c>Notify.Send</c> buffered into Store-and-Forward, then drives it through
|
||||
/// a real central <see cref="NotificationOutboxActor"/> so the
|
||||
/// <see cref="AuditKind.NotifyDeliver"/> dispatch rows materialise. The
|
||||
/// dispatcher echoes <c>OriginParentExecutionId</c> off the
|
||||
/// <c>NotificationSubmit</c> onto every <c>NotifyDeliver</c> row — the
|
||||
/// cross-execution linkage under test on the central side.
|
||||
/// </summary>
|
||||
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<NotificationSubmit>(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<NotificationOutboxActor>.Instance)));
|
||||
|
||||
// Ingest the genuine site submission, then run one dispatch sweep.
|
||||
var ack = await outboxActor.Ask<NotificationSubmitAck>(
|
||||
submit, TimeSpan.FromSeconds(15));
|
||||
Assert.True(ack.Accepted, ack.Error);
|
||||
outboxActor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Polls the site SQLite hot-path until every audit <see cref="AuditKind"/>
|
||||
/// the routed run is expected to emit — sync <c>ApiCall</c>, the cached
|
||||
/// <c>CachedSubmit</c>/<c>ApiCallCached</c>/<c>CachedResolve</c> lifecycle,
|
||||
/// and <c>NotifySend</c> — is durably present (Pending or Forwarded).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The routed run's sync-<c>ApiCall</c> and <c>NotifySend</c> audit rows are
|
||||
/// written fire-and-forget (the script call must not block on the audit
|
||||
/// writer — alog.md §7), so the routed <c>RouteToCallAsync</c> returns
|
||||
/// before the background writer loop has committed those rows.
|
||||
/// <c>NotifySend</c> is emitted last and therefore settles last. This wait
|
||||
/// asserts the specific <b>Kinds</b> are present, not merely a row count: a
|
||||
/// bare count could be satisfied while the last-emitted <c>NotifySend</c>
|
||||
/// row was still in flight, letting the <c>SiteAuditTelemetryActor</c> drain
|
||||
/// only a partial snapshot and leave <c>NotifySend</c> stranded for a later
|
||||
/// tick — the emit-vs-drain race that failed this test under full-suite load.
|
||||
/// </remarks>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stub <see cref="INotificationDeliveryAdapter"/> that always reports a
|
||||
/// successful delivery — a single dispatch sweep then yields one
|
||||
/// <see cref="AuditStatus.Attempted"/> + one <see cref="AuditStatus.Delivered"/>
|
||||
/// <see cref="AuditKind.NotifyDeliver"/> row.
|
||||
/// </summary>
|
||||
private sealed class AlwaysDeliversAdapter : INotificationDeliveryAdapter
|
||||
{
|
||||
public NotificationType Type => NotificationType.Email;
|
||||
|
||||
public Task<DeliveryOutcome> DeliverAsync(
|
||||
ScadaLink.Commons.Entities.Notifications.Notification notification,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(DeliveryOutcome.Success("ops@example.com"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-process stand-in for the cross-cluster routing transport
|
||||
/// (<c>CommunicationServiceInstanceRouter</c> →
|
||||
/// <c>CommunicationService</c> → site <c>DeploymentManagerActor</c>). On a
|
||||
/// routed <c>Call</c> it does exactly what the production site handler does:
|
||||
/// it reads <see cref="RouteToCallRequest.ParentExecutionId"/> off the wire
|
||||
/// request and threads it into a fresh routed <see cref="ScriptRuntimeContext"/>
|
||||
/// as <c>parentExecutionId</c>, then runs the routed script's three
|
||||
/// trust-boundary actions (sync <c>ExternalSystem.Call</c>, a cached call and
|
||||
/// a <c>Notify.Send</c>). The routed context still mints its OWN fresh
|
||||
/// <c>ExecutionId</c> — only the parent pointer is inherited.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>NotificationId</c> the routed <c>Notify.Send</c> minted, captured
|
||||
/// so the test can drain the buffered <see cref="NotificationSubmit"/>.
|
||||
/// </summary>
|
||||
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<RouteToCallResponse> RouteToCallAsync(
|
||||
string siteId, RouteToCallRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var compilationService = new ScriptCompilationService(
|
||||
NullLogger<ScriptCompilationService>.Instance);
|
||||
var sharedScriptLibrary = new SharedScriptLibrary(
|
||||
compilationService, NullLogger<SharedScriptLibrary>.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<RouteToGetAttributesResponse> RouteToGetAttributesAsync(
|
||||
string siteId, RouteToGetAttributesRequest request, CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<RouteToSetAttributesResponse> RouteToSetAttributesAsync(
|
||||
string siteId, RouteToSetAttributesRequest request, CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -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) ----- //
|
||||
|
||||
/// <summary>
|
||||
/// The pre-ParentExecutionId-branch <c>AuditLog</c> schema — the 21-column
|
||||
/// CREATE TABLE that HAS <c>ExecutionId</c> but is WITHOUT
|
||||
/// <c>ParentExecutionId</c>. A deployment that ran the ExecutionId branch
|
||||
/// already has an on-disk <c>auditlog.db</c> in exactly this shape, and
|
||||
/// <c>CREATE TABLE IF NOT EXISTS</c> is a no-op against it.
|
||||
/// </summary>
|
||||
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);
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CachedCallTelemetry>();
|
||||
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var sut = CreateSut();
|
||||
await sut.OnAttemptCompletedAsync(Ctx(
|
||||
CachedCallAttemptOutcome.TransientFailure,
|
||||
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<CachedCallTelemetry>();
|
||||
_forwarder.ForwardAsync(Arg.Do<CachedCallTelemetry>(t => captured.Add(t)), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var sut = CreateSut();
|
||||
await sut.OnAttemptCompletedAsync(Ctx(
|
||||
CachedCallAttemptOutcome.Delivered,
|
||||
channel: "DbOutbound",
|
||||
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<CachedCallTelemetry>(t => captured = t), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var sut = CreateSut();
|
||||
await sut.OnAttemptCompletedAsync(Ctx(CachedCallAttemptOutcome.TransientFailure));
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Null(captured!.Audit.ParentExecutionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -27,6 +27,10 @@ namespace ScadaLink.CentralUI.PlaywrightTests.Audit;
|
||||
/// <item><c>DrillInFromExecutionId_LandsOnAuditLogWithFilterContext</c> — the
|
||||
/// <c>?executionId=</c> drill-in (the drawer's "View this execution" action)
|
||||
/// auto-loads the grid filtered by ExecutionId.</item>
|
||||
/// <item><c>DrillInFromParentExecution_FiltersGridToSpawnerExecution</c> — the
|
||||
/// drawer's "View parent execution" action on a spawned (child) row drills in
|
||||
/// to <c>?executionId={ParentExecutionId}</c>, auto-loading the spawner's
|
||||
/// rows.</item>
|
||||
/// <item><c>NotificationsPage_HasViewAuditHistoryLink_WhenNotificationsExist</c> —
|
||||
/// the report page wires drill-in links when notifications are present.</item>
|
||||
/// <item><c>ExportCsv_LinkIsVisibleAndDownloads</c> — Export CSV button gated on
|
||||
@@ -350,6 +354,163 @@ 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 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()
|
||||
{
|
||||
|
||||
@@ -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<AuditLogPaging>(),
|
||||
@@ -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<AuditLogPaging>(),
|
||||
@@ -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<AuditLogQueryFilter>(f => f.ParentExecutionId == null),
|
||||
Arg.Any<AuditLogPaging>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-only authentication handler that signs every request in as an Admin.
|
||||
/// Admin is in <c>AuditExportRoles</c>, so the endpoint's AuditExport policy
|
||||
|
||||
@@ -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,91 @@ 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<AuditDrilldownDrawer>(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<AuditDrilldownDrawer>(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<AuditDrilldownDrawer>(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<NavigationManager>();
|
||||
Assert.Contains($"/audit/log?executionId={parent}", nav.Uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drawer_NullExecutionId_HidesViewExecutionChainButton()
|
||||
{
|
||||
var ev = MakeEvent(executionId: null);
|
||||
|
||||
var cut = Render<AuditDrilldownDrawer>(p => p
|
||||
.Add(c => c.Event, ev)
|
||||
.Add(c => c.IsOpen, true));
|
||||
|
||||
Assert.DoesNotContain("data-test=\"view-execution-chain\"", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drawer_NonNullExecutionId_ShowsViewExecutionChainButton()
|
||||
{
|
||||
var ev = MakeEvent(executionId: Guid.Parse("aaaaaaaa-9999-8888-7777-666666666666"));
|
||||
|
||||
var cut = Render<AuditDrilldownDrawer>(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<AuditDrilldownDrawer>(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<NavigationManager>();
|
||||
Assert.Contains($"/audit/execution-tree?executionId={exec}", nav.Uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyAsCurl_InvokesClipboard_WithCurlString()
|
||||
{
|
||||
|
||||
@@ -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<AuditFilterBar>(p => p
|
||||
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(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<AuditFilterBar>(p => p
|
||||
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
|
||||
|
||||
// Blank — never typed into.
|
||||
cut.Find("[data-test=\"filter-apply\"]").Click();
|
||||
Assert.NotNull(captured);
|
||||
Assert.Null(captured!.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()
|
||||
{
|
||||
|
||||
@@ -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<AuditEvent>
|
||||
{
|
||||
MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
|
||||
});
|
||||
|
||||
var cut = Render<AuditResultsGrid>(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<AuditResultsGrid>(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<AuditResultsGrid>(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)
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
using Bunit;
|
||||
using ScadaLink.CentralUI.Components.Audit;
|
||||
using ScadaLink.Commons.Types.Audit;
|
||||
|
||||
namespace ScadaLink.CentralUI.Tests.Components.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit tests for <see cref="ExecutionTree"/> (Audit Log ParentExecutionId
|
||||
/// feature, Task 10). The component takes the FLAT
|
||||
/// <see cref="ExecutionTreeNode"/> list the repository returns, assembles it
|
||||
/// into a tree by joining <see cref="ExecutionTreeNode.ParentExecutionId"/> to a
|
||||
/// parent node's <see cref="ExecutionTreeNode.ExecutionId"/>, 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).
|
||||
/// </summary>
|
||||
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<string>() : new[] { "ApiOutbound" },
|
||||
rowCount == 0 ? Array.Empty<string>() : 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<ExecutionTreeNode> { Node(id, null) };
|
||||
|
||||
var cut = Render<ExecutionTree>(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<ExecutionTreeNode>
|
||||
{
|
||||
Node(grandchild, child),
|
||||
Node(root, null),
|
||||
Node(child, root),
|
||||
};
|
||||
|
||||
var cut = Render<ExecutionTree>(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<ExecutionTreeNode>
|
||||
{
|
||||
Node(stubParent, null, rowCount: 0),
|
||||
Node(child, stubParent),
|
||||
};
|
||||
|
||||
var cut = Render<ExecutionTree>(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<ExecutionTreeNode>
|
||||
{
|
||||
Node(root, null),
|
||||
Node(child, root),
|
||||
};
|
||||
|
||||
var cut = Render<ExecutionTree>(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 <a href> 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<ExecutionTreeNode>
|
||||
{
|
||||
Node(root, null),
|
||||
Node(child, root),
|
||||
};
|
||||
|
||||
var cut = Render<ExecutionTree>(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<ExecutionTree>(p => p
|
||||
.Add(c => c.Nodes, (IReadOnlyList<ExecutionTreeNode>)Array.Empty<ExecutionTreeNode>())
|
||||
.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<ExecutionTreeNode>
|
||||
{
|
||||
Node(a, b),
|
||||
Node(b, a),
|
||||
};
|
||||
|
||||
var cut = Render<ExecutionTree>(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}\""));
|
||||
}
|
||||
|
||||
[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<ExecutionTreeNode>
|
||||
{
|
||||
Node(root, null),
|
||||
Node(child, root),
|
||||
Node(grandchild, child),
|
||||
};
|
||||
|
||||
var cut = Render<ExecutionTree>(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;
|
||||
while ((idx = haystack.IndexOf(needle, idx, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
count++;
|
||||
idx += needle.Length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -214,6 +214,45 @@ public class AuditLogPageScaffoldTests : BunitContext
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[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<IAuditLogQueryService>();
|
||||
_queryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(new List<AuditEvent>()));
|
||||
|
||||
var cut = RenderAuditLogPageWithQuery($"parentExecutionId={parentExecutionId}", "Admin");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
_queryService.Received().QueryAsync(
|
||||
Arg.Is<AuditLogQueryFilter>(f => f.ParentExecutionId == parentExecutionId),
|
||||
Arg.Any<AuditLogPaging?>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithUnparseableParentExecutionIdParam_IsSilentlyDropped_NoAutoLoad()
|
||||
{
|
||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||
|
||||
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<AuditLogQueryFilter>(),
|
||||
Arg.Any<AuditLogPaging?>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithTargetParam_AppliesTargetFilter()
|
||||
{
|
||||
|
||||
124
tests/ScadaLink.CentralUI.Tests/Pages/ExecutionTreePageTests.cs
Normal file
124
tests/ScadaLink.CentralUI.Tests/Pages/ExecutionTreePageTests.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit tests for <see cref="ExecutionTreePage"/> (Audit Log ParentExecutionId
|
||||
/// feature, Task 10). The page is reached via the "View execution chain"
|
||||
/// drill-in at <c>/audit/execution-tree?executionId={guid}</c>. It parses the
|
||||
/// query-string id, calls <see cref="IAuditLogQueryService.GetExecutionTreeAsync"/>,
|
||||
/// and hands the flat node list to the <c>ExecutionTree</c> component.
|
||||
/// </summary>
|
||||
public class ExecutionTreePageTests : BunitContext
|
||||
{
|
||||
private IAuditLogQueryService _queryService = Substitute.For<IAuditLogQueryService>();
|
||||
|
||||
private static ClaimsPrincipal BuildPrincipal(params string[] roles)
|
||||
{
|
||||
var claims = new List<Claim> { new("Username", "tester") };
|
||||
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
}
|
||||
|
||||
private IRenderedComponent<ExecutionTreePage> RenderPage(string? query, params string[] roles)
|
||||
{
|
||||
var user = BuildPrincipal(roles);
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
Services.AddAuthorizationCore();
|
||||
AuthorizationPolicies.AddScadaLinkAuthorization(Services);
|
||||
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
|
||||
Services.AddSingleton(_queryService);
|
||||
|
||||
if (!string.IsNullOrEmpty(query))
|
||||
{
|
||||
var nav = (BunitNavigationManager)Services.GetRequiredService<NavigationManager>();
|
||||
nav.NavigateTo($"/audit/execution-tree?{query}");
|
||||
}
|
||||
|
||||
var host = Render<CascadingAuthenticationState>(parameters => parameters
|
||||
.Add(p => p.ChildContent, (RenderFragment)(builder =>
|
||||
{
|
||||
builder.OpenComponent<ExecutionTreePage>(0);
|
||||
builder.CloseComponent();
|
||||
})));
|
||||
|
||||
return host.FindComponent<ExecutionTreePage>();
|
||||
}
|
||||
|
||||
private static ExecutionTreeNode Node(Guid id, Guid? parent, int rowCount = 2)
|
||||
=> new(
|
||||
id, parent, rowCount,
|
||||
rowCount == 0 ? Array.Empty<string>() : new[] { "ApiOutbound" },
|
||||
rowCount == 0 ? Array.Empty<string>() : 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<IAuditLogQueryService>();
|
||||
_queryService.GetExecutionTreeAsync(child, Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(new List<ExecutionTreeNode>
|
||||
{
|
||||
Node(root, null),
|
||||
Node(child, root),
|
||||
}));
|
||||
|
||||
var cut = RenderPage($"executionId={child}", "Admin");
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
_queryService.Received().GetExecutionTreeAsync(child, Arg.Any<CancellationToken>());
|
||||
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<IAuditLogQueryService>();
|
||||
|
||||
var cut = RenderPage(query: null, "Admin");
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Contains("Execution Chain", cut.Markup));
|
||||
_queryService.DidNotReceive().GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateWithUnparseableExecutionId_RendersGuidancePrompt_NoServiceCall()
|
||||
{
|
||||
_queryService = Substitute.For<IAuditLogQueryService>();
|
||||
|
||||
var cut = RenderPage("executionId=not-a-guid", "Admin");
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Contains("Execution Chain", cut.Markup));
|
||||
_queryService.DidNotReceive().GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExecutionTreePage_HasOperationalAuditAuthorizeAttribute()
|
||||
{
|
||||
var attributes = typeof(ExecutionTreePage)
|
||||
.GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
|
||||
.Cast<AuthorizeAttribute>()
|
||||
.ToList();
|
||||
|
||||
Assert.Contains(attributes, a => a.Policy == AuthorizationPolicies.OperationalAudit);
|
||||
}
|
||||
}
|
||||
@@ -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<IAuditLogRepository>();
|
||||
var executionId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
|
||||
var expected = new List<ExecutionTreeNode>
|
||||
{
|
||||
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<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(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<CancellationToken>());
|
||||
}
|
||||
|
||||
[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<IAuditLogRepository>();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped<IAuditLogRepository>(_ =>
|
||||
{
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
repo.GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>()));
|
||||
resolvedRepos.Add(repo);
|
||||
return repo;
|
||||
});
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var sut = new AuditLogQueryService(
|
||||
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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<NotificationSubmit>(json);
|
||||
|
||||
Assert.NotNull(roundTripped);
|
||||
Assert.Equal(parentExecutionId, roundTripped!.OriginParentExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_ValueEquality_EqualWhenAllFieldsMatch()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using Xunit;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Tests.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log ParentExecutionId integration test for the
|
||||
/// <c>AddNotificationOriginParentExecutionId</c> migration: applies the EF
|
||||
/// migrations to a freshly-created MSSQL test database on the running
|
||||
/// infra/mssql container and asserts that the <c>Notifications</c> table carries
|
||||
/// the new <c>OriginParentExecutionId</c> column as a nullable
|
||||
/// <c>uniqueidentifier</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Unlike <c>AuditLog</c>, the <c>Notifications</c> table is not partitioned, so
|
||||
/// the column is a plain metadata-only <c>ALTER TABLE … ADD</c> with no index.
|
||||
/// Tests pair <see cref="SkippableFactAttribute"/> with <c>Skip.IfNot(...)</c> so
|
||||
/// the runner reports them as Skipped (not Passed) when MSSQL is unreachable. The
|
||||
/// fixture applies the migrations once at construction time.
|
||||
/// </remarks>
|
||||
public class AddNotificationOriginParentExecutionIdMigrationTests : IClassFixture<MsSqlMigrationFixture>
|
||||
{
|
||||
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<int>(
|
||||
"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<string?>(
|
||||
"SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginParentExecutionId';");
|
||||
Assert.Equal("uniqueidentifier", dataType);
|
||||
|
||||
var isNullable = await ScalarAsync<string?>(
|
||||
"SELECT IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_NAME = 'Notifications' AND COLUMN_NAME = 'OriginParentExecutionId';");
|
||||
Assert.Equal("YES", isNullable);
|
||||
}
|
||||
|
||||
// --- helpers ------------------------------------------------------------
|
||||
|
||||
private async Task<T> ScalarAsync<T>(string sql)
|
||||
{
|
||||
await using var conn = _fixture.OpenConnection();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
var result = await cmd.ExecuteScalarAsync();
|
||||
if (result is null || result is DBNull)
|
||||
{
|
||||
return default!;
|
||||
}
|
||||
return (T)Convert.ChangeType(result, typeof(T) == typeof(string) ? typeof(string) : Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T))!;
|
||||
}
|
||||
}
|
||||
@@ -275,6 +275,35 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
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()
|
||||
{
|
||||
@@ -717,6 +746,184 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
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. 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);
|
||||
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 };
|
||||
|
||||
// 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);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
[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<SqlException>(() => call);
|
||||
}
|
||||
|
||||
private async Task<T> ScalarAsync<T>(ScadaLinkDbContext context, string sql)
|
||||
{
|
||||
var conn = context.Database.GetDbConnection();
|
||||
@@ -754,7 +961,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
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 +973,6 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||
SourceSiteId = siteId,
|
||||
ErrorMessage = errorMessage,
|
||||
ExecutionId = executionId,
|
||||
ParentExecutionId = parentExecutionId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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<IInstanceLocator>();
|
||||
locator.GetSiteIdForInstanceAsync("inst-1", Arg.Any<CancellationToken>()).Returns("SiteA");
|
||||
var router = Substitute.For<IInstanceRouter>();
|
||||
RouteToCallRequest? captured = null;
|
||||
router.RouteToCallAsync("SiteA", Arg.Do<RouteToCallRequest>(r => captured = r), Arg.Any<CancellationToken>())
|
||||
.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<string, object?>(), 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<IInstanceLocator>();
|
||||
locator.GetSiteIdForInstanceAsync("inst-1", Arg.Any<CancellationToken>()).Returns("SiteA");
|
||||
var router = Substitute.For<IInstanceRouter>();
|
||||
RouteToCallRequest? captured = null;
|
||||
router.RouteToCallAsync("SiteA", Arg.Do<RouteToCallRequest>(r => captured = r), Arg.Any<CancellationToken>())
|
||||
.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<string, object?>(), route, TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.True(result.Success, result.ErrorMessage);
|
||||
Assert.NotNull(captured);
|
||||
Assert.Null(captured!.ParentExecutionId);
|
||||
}
|
||||
|
||||
private sealed class CompileLogCounter
|
||||
{
|
||||
public int CompilationFailures;
|
||||
|
||||
@@ -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<Guid>(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()
|
||||
{
|
||||
|
||||
@@ -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<RouteToCallRequest>(r => captured = r), Arg.Any<CancellationToken>())
|
||||
.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<RouteToCallRequest>(r => captured = r), Arg.Any<CancellationToken>())
|
||||
.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<RouteToCallRequest>(r => captured = r), Arg.Do<CancellationToken>(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()
|
||||
{
|
||||
|
||||
@@ -96,6 +96,10 @@ public class SiteAuditPushFlowTests : TestKit
|
||||
public Task<AuditLogKpiSnapshot> GetKpiSnapshotAsync(
|
||||
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
||||
Guid executionId, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
private static AuditEvent NewPendingEvent(Guid id) => new()
|
||||
|
||||
@@ -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<string, Microsoft.Extensions.Primitives.StringValues>
|
||||
{
|
||||
["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<string, Microsoft.Extensions.Primitives.StringValues>
|
||||
{
|
||||
["parentExecutionId"] = "not-a-guid",
|
||||
});
|
||||
|
||||
var filter = AuditEndpoints.ParseFilter(query);
|
||||
|
||||
Assert.Null(filter.ParentExecutionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Query_RepeatedChannelParams_ReachRepositoryAsMultiValueFilter()
|
||||
{
|
||||
|
||||
@@ -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<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new[] { notification });
|
||||
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
|
||||
var actor = CreateActor([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var attempted = EventsByStatus(AuditStatus.Attempted);
|
||||
Assert.Single(attempted);
|
||||
Assert.Equal(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<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new[] { notification });
|
||||
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
|
||||
var actor = CreateActor([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var attempted = EventsByStatus(AuditStatus.Attempted);
|
||||
Assert.Single(attempted);
|
||||
Assert.Null(attempted[0].ParentExecutionId);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Attempt_TransientFailure_EmitsEvent_StatusAttempted_ErrorMessageSet()
|
||||
{
|
||||
|
||||
@@ -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<CancellationToken>());
|
||||
}
|
||||
|
||||
[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<Notification>(), Arg.Any<CancellationToken>())
|
||||
.Returns(true);
|
||||
var parentExecutionId = Guid.NewGuid();
|
||||
var submit = MakeSubmit(originParentExecutionId: parentExecutionId);
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(submit, TestActor);
|
||||
|
||||
ExpectMsg<NotificationSubmitAck>();
|
||||
_repository.Received(1).InsertIfNotExistsAsync(
|
||||
Arg.Is<Notification>(n => n.OriginParentExecutionId == parentExecutionId),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotificationSubmit_NullOriginParentExecutionId_PersistsNull()
|
||||
{
|
||||
_repository.InsertIfNotExistsAsync(Arg.Any<Notification>(), Arg.Any<CancellationToken>())
|
||||
.Returns(true);
|
||||
var submit = MakeSubmit(originParentExecutionId: null);
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(submit, TestActor);
|
||||
|
||||
ExpectMsg<NotificationSubmitAck>();
|
||||
_repository.Received(1).InsertIfNotExistsAsync(
|
||||
Arg.Is<Notification>(n => n.OriginParentExecutionId == null),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateSubmit_RepositoryReturnsFalse_StillAcksAccepted()
|
||||
{
|
||||
|
||||
@@ -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<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new[] { notification });
|
||||
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
|
||||
var actor = CreateActor([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var delivered = EventsByStatus(AuditStatus.Delivered);
|
||||
Assert.Single(delivered);
|
||||
Assert.Equal(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<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new[] { notification });
|
||||
var adapter = new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
|
||||
var actor = CreateActor([adapter]);
|
||||
|
||||
actor.Tell(InternalMessages.DispatchTick.Instance);
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
var delivered = EventsByStatus(AuditStatus.Delivered);
|
||||
Assert.Single(delivered);
|
||||
Assert.Null(delivered[0].ParentExecutionId);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Terminal_Parked_OnPermanentFailure_EmitsEvent_StatusParked()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a config carrying a single callable (no-trigger) script that
|
||||
/// returns a constant — enough for an inbound <see cref="RouteToCallRequest"/>
|
||||
/// to be routed end-to-end through the Instance/Script/ScriptExecution actors.
|
||||
/// </summary>
|
||||
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<DeploymentStatusResponse>(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<DeploymentStatusResponse>(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<RouteToCallResponse>(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<DeploymentStatusResponse>(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<RouteToCallResponse>(TimeSpan.FromSeconds(10));
|
||||
Assert.Equal("route-corr-2", response.CorrelationId);
|
||||
Assert.True(response.Success, $"Routed call failed: {response.ErrorMessage}");
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user