Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b16a48886 | |||
| 990731d12f | |||
| fd12021984 | |||
| 4002f4197b | |||
| 6ffa47f258 | |||
| c9229c35fc |
@@ -0,0 +1,115 @@
|
|||||||
|
# Audit Log — ExecutionId Universal Correlation (Design)
|
||||||
|
|
||||||
|
**Date:** 2026-05-21
|
||||||
|
**Status:** Validated — ready for implementation planning.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The audit `CorrelationId` column is overloaded with three incompatible meanings —
|
||||||
|
`TrackedOperationId` for cached calls, `NotificationId` for notifications, the
|
||||||
|
script-execution id for sync calls (added 2026-05-21), and request-local ids for
|
||||||
|
inbound. It is `NULL` for sync one-shot calls. There is no single value that ties
|
||||||
|
together *everything one script run (or inbound request) did*: a run that makes a
|
||||||
|
sync API call, a cached call and a notification produces three unrelated
|
||||||
|
correlation ids, and nothing links the cached call's lifecycle rows back to the
|
||||||
|
run that launched them.
|
||||||
|
|
||||||
|
A single `CorrelationId` column cannot serve both scopes — the **operation
|
||||||
|
lifecycle** (a cached call's `Submit→Attempted→Resolve`; a notification's
|
||||||
|
`Send→Deliver`, which the Site Calls / Notifications "View audit history"
|
||||||
|
drill-ins depend on) and the **execution trace** (all operations of one run).
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Add a dedicated, nullable **`ExecutionId`** column to the audit row. It identifies
|
||||||
|
the originating **script execution** or **inbound API request**. Every audit row
|
||||||
|
that execution produces carries the same `ExecutionId`. `CorrelationId` is left
|
||||||
|
exactly as it is — it keeps the per-operation lifecycle meaning, so the existing
|
||||||
|
operation drill-ins are unaffected.
|
||||||
|
|
||||||
|
Result: `WHERE ExecutionId = X` returns every audit row of one run — sync
|
||||||
|
`ApiCall`/`DbWrite`, the whole cached-call lifecycle, `NotifySend`,
|
||||||
|
`NotifyDeliver`, and the inbound row — across both the site and central tables.
|
||||||
|
|
||||||
|
`ScriptRuntimeContext` already holds a per-execution id (`_auditCorrelationId`,
|
||||||
|
added 2026-05-21). That id becomes the `ExecutionId`; this work stamps it into the
|
||||||
|
new column from every emitter and threads it to the two paths where the script
|
||||||
|
context is not in scope.
|
||||||
|
|
||||||
|
### Considered and rejected
|
||||||
|
|
||||||
|
- **Overload `CorrelationId`** with the execution id everywhere — breaks the
|
||||||
|
cached-call / notification "View audit history" drill-ins (they filter
|
||||||
|
`CorrelationId` by `TrackedOperationId` / `NotificationId`), or forces them to
|
||||||
|
show the whole run instead of the one operation.
|
||||||
|
- **Stash the execution id in `Extra` JSON** — no schema change, but `Extra` is
|
||||||
|
unindexed; filtering an audit table of this volume by it is unworkable.
|
||||||
|
|
||||||
|
## Schema changes (all additive, nullable — no backfill; pre-existing rows stay `NULL`)
|
||||||
|
|
||||||
|
| Where | Change |
|
||||||
|
|---|---|
|
||||||
|
| `ScadaLink.Commons` | `AuditEvent` record (and the site-local variant) gains `Guid? ExecutionId`. |
|
||||||
|
| Central MS SQL `AuditLog` | new `ExecutionId uniqueidentifier NULL` column + index `IX_AuditLog_Execution (ExecutionId)`. EF migration — additive nullable column is a metadata-only `ALTER`, fast even on the monthly-partitioned table. |
|
||||||
|
| Site SQLite `auditlog.db` `AuditLog` | new `ExecutionId TEXT NULL` column (`SqliteAuditWriter` schema + `MapRow`). |
|
||||||
|
| gRPC `AuditEventDto` (`sitestream.proto`) | additive `execution_id` field; `AuditEventDtoMapper` maps it both directions. |
|
||||||
|
| Central MS SQL `Notifications` | new `OriginExecutionId uniqueidentifier NULL` column — carries the originating run's id so the dispatcher can echo it onto `NotifyDeliver` audit rows. EF migration. |
|
||||||
|
|
||||||
|
`SiteCalls` needs no new column — the cached telemetry packet already carries the
|
||||||
|
audit half, which now has `ExecutionId` directly.
|
||||||
|
|
||||||
|
## Emitter coverage — every audit row carries `ExecutionId`
|
||||||
|
|
||||||
|
| Emitter | `ExecutionId` source |
|
||||||
|
|---|---|
|
||||||
|
| Sync `ApiCall`, sync `DbWrite` | `ScriptRuntimeContext` execution id (in scope today) |
|
||||||
|
| Cached call script-side rows (`CachedSubmit`, immediate `Attempted`/`CachedResolve`) | `ScriptRuntimeContext` execution id |
|
||||||
|
| Cached call **S&F retry-loop** rows (`CachedCallLifecycleBridge`) | threaded through the store-and-forward buffered message → `CachedCallAttemptContext` → the bridge. This same threading also fixes the pre-existing `SourceScript = NULL` gap on those rows (identical boundary). |
|
||||||
|
| `NotifySend` (site, script-side) | `ScriptRuntimeContext` execution id |
|
||||||
|
| `NotifyDeliver` (central dispatch) | `Notifications.OriginExecutionId` — the id rides on `NotificationSubmit`, is persisted on the `Notifications` row, and the dispatcher stamps it on every `NotifyDeliver` row |
|
||||||
|
| Inbound `InboundRequest` / `InboundAuthFailure` | request id minted once in `AuditWriteMiddleware` |
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
- **Site script run** — `ScriptRuntimeContext` generates the execution id (or is
|
||||||
|
given one); every emitter it owns stamps `ExecutionId`.
|
||||||
|
- **Buffered cached call** — the execution id rides on the S&F buffered message;
|
||||||
|
the retry loop reconstructs it into `CachedCallAttemptContext`;
|
||||||
|
`CachedCallLifecycleBridge` stamps it on the retry-loop audit rows.
|
||||||
|
- **Notification** — the `NotifySend` row stamps it site-side; the id travels on
|
||||||
|
`NotificationSubmit`, is stored as `Notifications.OriginExecutionId`, and the
|
||||||
|
dispatcher stamps every `NotifyDeliver` row it emits.
|
||||||
|
- **Inbound API request** — `AuditWriteMiddleware` mints a request id and stamps
|
||||||
|
the inbound audit row.
|
||||||
|
|
||||||
|
## UI / CLI surface
|
||||||
|
|
||||||
|
- **Central UI Audit Log page** — `ExecutionId` added as a results-grid column
|
||||||
|
(the grid already supports resize/reorder); an `ExecutionId` paste-filter in
|
||||||
|
the filter bar; the page accepts `?executionId=<guid>`; a row drill-in
|
||||||
|
"View this execution" → `/audit/log?executionId=<guid>`.
|
||||||
|
- **CLI** — `scadalink audit query --execution-id <guid>`.
|
||||||
|
- **ManagementService** — `/api/audit/query` and the export endpoint accept an
|
||||||
|
`executionId` filter parameter.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
- Two additive nullable columns; additive proto field; additive message-contract
|
||||||
|
fields — all version-compatible. No data backfill; historical rows keep
|
||||||
|
`ExecutionId = NULL`.
|
||||||
|
- `CorrelationId` semantics unchanged — every existing drill-in keeps working.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Repository: query-by-`ExecutionId`; migration smoke test.
|
||||||
|
- Emitter unit tests: each emitter stamps `ExecutionId`; the cached-call lifecycle
|
||||||
|
rows from one run share it; `NotifyDeliver` echoes `Notifications.OriginExecutionId`.
|
||||||
|
- Integration: a script run that does a sync call + a cached call + a notification
|
||||||
|
→ all resulting audit rows share one `ExecutionId` end-to-end.
|
||||||
|
- Central UI: bUnit (grid column, filter, drill-in) + Playwright.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Bridging the inbound request id into the routed site script's execution
|
||||||
|
(cross-cluster threading) — a separate future change.
|
||||||
|
- Backfilling `ExecutionId` on historical audit rows.
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# Audit Log ExecutionId — Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to execute this plan task-by-task (fresh implementer per task + spec review + code-quality review).
|
||||||
|
|
||||||
|
**Goal:** Add a dedicated `ExecutionId` column to the Audit Log — one universal correlation value, stamped on every audit row, identifying the originating script execution or inbound request.
|
||||||
|
|
||||||
|
**Architecture:** Additive nullable `ExecutionId` (`Guid`) on the audit row (Commons `AuditEvent`, central MS SQL `AuditLog`, site SQLite `auditlog.db`, gRPC `AuditEventDto`). Every emitter stamps it; the `ScriptRuntimeContext` per-execution id is the source for site script runs, threaded through the S&F buffer for retry-loop cached rows and through `NotificationSubmit` → `Notifications.OriginExecutionId` for central `NotifyDeliver` rows. `CorrelationId` is left as the per-operation lifecycle id (and reverts to `null` for sync one-shot calls). Validated design: `docs/plans/2026-05-21-audit-executionid-design.md`.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 10, EF Core 10 (MS SQL + SQLite), Akka.NET, gRPC, Blazor Server + Bootstrap, System.CommandLine, xUnit + Akka.TestKit.Xunit2 + bUnit + NSubstitute/Moq, Playwright.
|
||||||
|
|
||||||
|
**Ground rules (every task):** branch is `feature/audit-executionid` (already created) — never commit to `main`. Edit in place; never touch `infra/*`; `docker/*` only if a task says so (none do). Stage with explicit `git add <path>` — never `git add .` / `commit -am`. TDD; full solution stays green (`dotnet build ScadaLink.slnx` 0 warnings — `TreatWarningsAsErrors` is on). Additive contract evolution. Do not push.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 0: Prep — verify branch + baseline
|
||||||
|
|
||||||
|
**Files:** none.
|
||||||
|
|
||||||
|
**Steps:** confirm `git branch --show-current` is `feature/audit-executionid`; `dotnet build ScadaLink.slnx` succeeds.
|
||||||
|
|
||||||
|
**Acceptance:** on the branch, solution builds clean.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Foundation — `AuditEvent.ExecutionId`, central `AuditLog` column, repository query
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs` — add `Guid? ExecutionId`.
|
||||||
|
- Modify: `src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs` — add `Guid? ExecutionId` filter dimension (single-value, like `CorrelationId`).
|
||||||
|
- Modify: `src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs` — map the column; add index `IX_AuditLog_Execution (ExecutionId)`.
|
||||||
|
- Create: a new EF migration under `src/ScadaLink.ConfigurationDatabase/Migrations/` — `AddAuditLogExecutionId` — `ExecutionId uniqueidentifier NULL` + the index. Additive nullable column (metadata-only ALTER, safe on the monthly-partitioned table). Mirror the existing `AddNotificationsTable` migration style.
|
||||||
|
- Modify: `src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs` — `QueryAsync` translates `filter.ExecutionId` to `e.ExecutionId == value` (mirror the `CorrelationId` clause). Keyset paging untouched.
|
||||||
|
- Test: `tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs` — `QueryAsync_FilterByExecutionId`; migration smoke if the suite has that pattern.
|
||||||
|
|
||||||
|
**Approach:** purely additive. `ExecutionId` is `Guid?` everywhere. Generate the migration with `dotnet ef migrations add` against the ConfigurationDatabase project (or hand-write mirroring an existing one — match how the repo does migrations).
|
||||||
|
|
||||||
|
**Commit:** `feat(auditlog): ExecutionId column on AuditEvent + central AuditLog`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Foundation — site SQLite + gRPC DTO
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs` — add `ExecutionId TEXT NULL` to the `auditlog.db` `AuditLog` table DDL; the insert command binds it; `MapRow` reads it back. (Site SQLite is created fresh by the writer — an additive column in the `CREATE TABLE` is enough; if the writer has any migration/ALTER path, extend it.)
|
||||||
|
- Modify: `src/ScadaLink.Communication/Protos/sitestream.proto` — add `string execution_id` to `AuditEventDto` (next free field number; additive). Rebuild regenerates the C# stubs.
|
||||||
|
- Modify: `src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs` — `ToDto`/`FromDto` map `ExecutionId` ↔ `execution_id` (Guid ↔ string; empty string ↔ null, mirroring the existing `CorrelationId` handling).
|
||||||
|
- Test: `tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs` (column present + round-trips); `tests/ScadaLink.Communication.Tests/AuditEventDtoMapperTests.cs` (ExecutionId round-trip incl. null).
|
||||||
|
|
||||||
|
**Commit:** `feat(auditlog): ExecutionId on site SQLite schema + gRPC AuditEventDto`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Site script-side emitters stamp `ExecutionId`
|
||||||
|
|
||||||
|
**What:** Every audit row a `ScriptRuntimeContext` emits gets `ExecutionId` = the context's per-execution id. Revert the interim "execution id in `CorrelationId` for sync rows" change so `CorrelationId` is purely per-operation again.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.SiteRuntime/Scripts/ScriptRuntimeContext.cs`:
|
||||||
|
- Rename the field `_auditCorrelationId` → `_executionId` (and the ctor param `auditCorrelationId` → `executionId`) for clarity; update XML docs. Thread it to the helpers as today.
|
||||||
|
- Sync `ApiCall` (`BuildCallAuditEvent`): set `ExecutionId = _executionId`; set `CorrelationId = null` (revert — sync one-shot calls have no operation lifecycle).
|
||||||
|
- Cached script-side rows (`CachedSubmit`, immediate `ApiCallCached`/`CachedResolve`): set `ExecutionId = _executionId`; `CorrelationId` stays `trackedId.Value`.
|
||||||
|
- `NotifySend` (`Notify.Send` emission): set `ExecutionId = _executionId`; `CorrelationId` stays the `NotificationId`.
|
||||||
|
- Modify: `src/ScadaLink.SiteRuntime/Scripts/AuditingDbConnection.cs` + `AuditingDbCommand.cs` — thread `_executionId` (rename from the audit-correlation param); sync `DbWrite` event sets `ExecutionId = _executionId` and `CorrelationId = null`. Cached DB write rows: `ExecutionId` set, `CorrelationId` stays `trackedId`.
|
||||||
|
- Test: extend `tests/ScadaLink.SiteRuntime.Tests/Scripts/ExternalSystemCallAuditEmissionTests.cs`, `DatabaseSyncEmissionTests.cs`, `ExternalSystemCachedCallEmissionTests.cs`, `DatabaseCachedWriteEmissionTests.cs`, `NotifySendAuditEmissionTests.cs`, and `ExecutionCorrelationContextTests.cs` — assert `ExecutionId` is the context's id on every row; assert sync rows now have `CorrelationId == null`; assert cached/notification rows keep their `CorrelationId`.
|
||||||
|
|
||||||
|
**Commit:** `feat(auditlog): site script-side emitters stamp ExecutionId`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Cached S&F retry-loop rows carry `ExecutionId`
|
||||||
|
|
||||||
|
**What:** Thread the execution id through the store-and-forward buffer so the retry-loop cached audit rows (`CachedCallLifecycleBridge`) carry `ExecutionId`. This same threading fixes the pre-existing `SourceScript = null` gap on those rows (identical boundary).
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: the S&F buffered cached-call message / `StoreAndForwardMessage` (or the cached-call payload) in `src/ScadaLink.StoreAndForward/` — carry the originating execution id (and source script) alongside the call.
|
||||||
|
- Modify: `CachedCallAttemptContext` (find it — `src/ScadaLink.AuditLog/Site/Telemetry/` or StoreAndForward) — add an `ExecutionId` (and `SourceScript`) field.
|
||||||
|
- Modify: `src/ScadaLink.AuditLog/Site/Telemetry/CachedCallLifecycleBridge.cs` `BuildPacket` — set `ExecutionId` from the context (and `SourceScript`, replacing the `SourceScript = null` line).
|
||||||
|
- Modify the enqueue path (`ExternalSystem.CachedCall` / `Database.CachedWrite` in `ScriptRuntimeContext`) so the execution id is written into the buffered message.
|
||||||
|
- Test: `tests/ScadaLink.AuditLog.Tests/` cached-telemetry tests + `tests/ScadaLink.StoreAndForward.Tests/` — retry-loop rows carry the originating `ExecutionId`.
|
||||||
|
|
||||||
|
**Note for implementer:** this is the deepest task — the threading touches StoreAndForward. If the buffered message can't cleanly carry the id, STOP and report before guessing.
|
||||||
|
|
||||||
|
**Commit:** `feat(auditlog): thread ExecutionId through S&F for retry-loop cached rows`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Central `NotifyDeliver` rows carry `ExecutionId`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.Commons/Entities/Notifications/Notification.cs` — add `Guid? OriginExecutionId`.
|
||||||
|
- Modify: `src/ScadaLink.Commons/Messages/Notification/` — `NotificationSubmit` carries `Guid? OriginExecutionId` (additive).
|
||||||
|
- Modify: `src/ScadaLink.ConfigurationDatabase/` — EF config + a new migration `AddNotificationOriginExecutionId` (`Notifications.OriginExecutionId uniqueidentifier NULL`).
|
||||||
|
- Modify: the site `NotifySend` forward path — the execution id (already on the `NotifySend` audit row from Task 3) also rides on the `NotificationSubmit` (set it where the submit is built — `ScriptRuntimeContext` `Notify.Send` / the S&F notification forwarder).
|
||||||
|
- Modify: `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs` — persist `OriginExecutionId` on insert; `BuildNotifyDeliverEvent` sets `ExecutionId = notification.OriginExecutionId`.
|
||||||
|
- Test: `tests/ScadaLink.NotificationOutbox.Tests/` — `NotifyDeliver` rows echo `OriginExecutionId`; `tests/ScadaLink.Commons.Tests/` contract shape.
|
||||||
|
|
||||||
|
**Commit:** `feat(auditlog): NotifyDeliver rows carry the originating ExecutionId`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Inbound rows carry `ExecutionId`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs` — `EmitInboundAudit` sets `ExecutionId` to the request id (it already mints a `Guid.NewGuid()` for the inbound `CorrelationId` per the 2026-05-21 change; reuse that one id for `ExecutionId` — and reconsider whether the inbound row's `CorrelationId` should now be `null` to keep `CorrelationId` purely per-operation; align with the Task 3 decision: inbound is a one-shot from the audit row's perspective → `CorrelationId = null`, `ExecutionId = <request id>`).
|
||||||
|
- Test: `tests/ScadaLink.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs` — inbound row carries a non-null `ExecutionId`; distinct per request.
|
||||||
|
|
||||||
|
**Commit:** `feat(auditlog): inbound audit rows carry ExecutionId`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Central UI — ExecutionId column, filter, drill-in
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditResultsGrid.razor` (+ `.razor.cs`) — add `ExecutionId` to the column set (the grid already supports resize/reorder + a `ColumnOrder`); render it (short form / monospace).
|
||||||
|
- Modify: `src/ScadaLink.CentralUI/Components/Audit/AuditFilterBar.razor` (+ `.razor.cs`) + `AuditQueryModel.cs` — an `ExecutionId` paste text-filter; `ToFilter` maps it to `AuditLogQueryFilter.ExecutionId`.
|
||||||
|
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs` — `ApplyQueryStringFilters` accepts `?executionId=<guid>`; `BuildExportUrl` emits it.
|
||||||
|
- Add a "View this execution" drill-in — a row/drilldown action linking `/audit/log?executionId=<guid>`. Mirror the existing `?correlationId=` drill-in.
|
||||||
|
- Test: `tests/ScadaLink.CentralUI.Tests/` bUnit (column renders, filter maps, query-param parsed); `tests/ScadaLink.CentralUI.PlaywrightTests/Audit/` (drill-in filters the grid).
|
||||||
|
|
||||||
|
Use the `frontend-design` skill for the column/filter styling.
|
||||||
|
|
||||||
|
**Commit:** `feat(centralui): ExecutionId column, filter and drill-in on the Audit Log page`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: CLI + ManagementService — ExecutionId filter
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.CLI/Commands/AuditCommands.cs` + `AuditQueryHelpers.cs` — `audit query --execution-id <guid>`; `AuditQueryArgs` + `BuildQueryString` emit `executionId`.
|
||||||
|
- Modify: `src/ScadaLink.ManagementService/AuditEndpoints.cs` `ParseFilter` — parse `executionId` query param into `AuditLogQueryFilter.ExecutionId` (lax-parse — unparseable dropped).
|
||||||
|
- Modify: `src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs` `ParseFilter` — same.
|
||||||
|
- Test: `tests/ScadaLink.CLI.Tests/`, `tests/ScadaLink.ManagementService.Tests/AuditEndpointsTests.cs`.
|
||||||
|
|
||||||
|
**Commit:** `feat(audit): ExecutionId filter in the CLI and ManagementService`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: End-to-end integration test + docs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/ScadaLink.IntegrationTests/AuditLog/ExecutionIdCorrelationTests.cs` — boot a site+central pair; run a script that does a sync `ExternalSystem.Call`, a cached call, and a `Notify.Send`; assert every resulting audit row (site + central) shares one `ExecutionId`.
|
||||||
|
- Modify: `docs/requirements/Component-AuditLog.md` — add `ExecutionId` to the schema table and a sentence on its meaning vs `CorrelationId`. (Do NOT modify `alog.md` — it is the locked v1 spec.)
|
||||||
|
- Modify: `CLAUDE.md` — one line under the Centralized Audit Log decisions noting `ExecutionId` as the universal per-run correlation value.
|
||||||
|
|
||||||
|
**Commit:** `test(auditlog): end-to-end ExecutionId correlation + docs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final review
|
||||||
|
|
||||||
|
Dispatch a final cross-cutting review of the whole branch; full `dotnet build` + `dotnet test ScadaLink.slnx`; hand back to the user for the push/merge/redeploy decision (do not push).
|
||||||
|
|
||||||
|
## Dependency summary
|
||||||
|
|
||||||
|
0 blocks all. 2 blockedBy 1. 3 blockedBy 2. 4 blockedBy 3. 5 blockedBy 2. 6 blockedBy 2. 7 blockedBy 1. 8 blockedBy 1. 9 blockedBy 3,4,5,6,7,8. Execution order: 0 → 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → final review.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"planPath": "docs/plans/2026-05-21-audit-executionid.md",
|
||||||
|
"tasks": [
|
||||||
|
{"id": 50, "subject": "Task 0: Prep — verify branch + baseline", "status": "pending"},
|
||||||
|
{"id": 51, "subject": "Task 1: Foundation — AuditEvent.ExecutionId + central AuditLog column + repo query", "status": "pending", "blockedBy": [50]},
|
||||||
|
{"id": 52, "subject": "Task 2: Foundation — site SQLite + gRPC DTO", "status": "pending", "blockedBy": [51]},
|
||||||
|
{"id": 53, "subject": "Task 3: Site script-side emitters stamp ExecutionId", "status": "pending", "blockedBy": [52]},
|
||||||
|
{"id": 54, "subject": "Task 4: Cached S&F retry-loop rows carry ExecutionId", "status": "pending", "blockedBy": [53]},
|
||||||
|
{"id": 55, "subject": "Task 5: Central NotifyDeliver rows carry ExecutionId", "status": "pending", "blockedBy": [52]},
|
||||||
|
{"id": 56, "subject": "Task 6: Inbound audit rows carry ExecutionId", "status": "pending", "blockedBy": [52]},
|
||||||
|
{"id": 57, "subject": "Task 7: Central UI — ExecutionId column, filter, drill-in", "status": "pending", "blockedBy": [51]},
|
||||||
|
{"id": 58, "subject": "Task 8: CLI + ManagementService — ExecutionId filter", "status": "pending", "blockedBy": [51]},
|
||||||
|
{"id": 59, "subject": "Task 9: End-to-end integration test + docs", "status": "pending", "blockedBy": [53, 54, 55, 56, 57, 58]}
|
||||||
|
],
|
||||||
|
"lastUpdated": "2026-05-21T00:00:00Z"
|
||||||
|
}
|
||||||
@@ -114,6 +114,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
PayloadTruncated INTEGER NOT NULL,
|
PayloadTruncated INTEGER NOT NULL,
|
||||||
Extra TEXT NULL,
|
Extra TEXT NULL,
|
||||||
ForwardState TEXT NOT NULL,
|
ForwardState TEXT NOT NULL,
|
||||||
|
ExecutionId TEXT NULL,
|
||||||
PRIMARY KEY (EventId)
|
PRIMARY KEY (EventId)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred
|
CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred
|
||||||
@@ -221,12 +222,14 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
||||||
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
|
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
|
||||||
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
|
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
|
||||||
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState
|
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
|
||||||
|
ExecutionId
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$EventId, $OccurredAtUtc, $Channel, $Kind, $CorrelationId,
|
$EventId, $OccurredAtUtc, $Channel, $Kind, $CorrelationId,
|
||||||
$SourceSiteId, $SourceInstanceId, $SourceScript, $Actor, $Target,
|
$SourceSiteId, $SourceInstanceId, $SourceScript, $Actor, $Target,
|
||||||
$Status, $HttpStatus, $DurationMs, $ErrorMessage, $ErrorDetail,
|
$Status, $HttpStatus, $DurationMs, $ErrorMessage, $ErrorDetail,
|
||||||
$RequestSummary, $ResponseSummary, $PayloadTruncated, $Extra, $ForwardState
|
$RequestSummary, $ResponseSummary, $PayloadTruncated, $Extra, $ForwardState,
|
||||||
|
$ExecutionId
|
||||||
);
|
);
|
||||||
""";
|
""";
|
||||||
|
|
||||||
@@ -250,6 +253,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
var pPayloadTruncated = cmd.Parameters.Add("$PayloadTruncated", SqliteType.Integer);
|
var pPayloadTruncated = cmd.Parameters.Add("$PayloadTruncated", SqliteType.Integer);
|
||||||
var pExtra = cmd.Parameters.Add("$Extra", SqliteType.Text);
|
var pExtra = cmd.Parameters.Add("$Extra", SqliteType.Text);
|
||||||
var pForwardState = cmd.Parameters.Add("$ForwardState", SqliteType.Text);
|
var pForwardState = cmd.Parameters.Add("$ForwardState", SqliteType.Text);
|
||||||
|
var pExecutionId = cmd.Parameters.Add("$ExecutionId", SqliteType.Text);
|
||||||
|
|
||||||
foreach (var pending in batch)
|
foreach (var pending in batch)
|
||||||
{
|
{
|
||||||
@@ -274,6 +278,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
pPayloadTruncated.Value = e.PayloadTruncated ? 1 : 0;
|
pPayloadTruncated.Value = e.PayloadTruncated ? 1 : 0;
|
||||||
pExtra.Value = (object?)e.Extra ?? DBNull.Value;
|
pExtra.Value = (object?)e.Extra ?? DBNull.Value;
|
||||||
pForwardState.Value = (e.ForwardState ?? AuditForwardState.Pending).ToString();
|
pForwardState.Value = (e.ForwardState ?? AuditForwardState.Pending).ToString();
|
||||||
|
pExecutionId.Value = (object?)e.ExecutionId?.ToString() ?? DBNull.Value;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -331,7 +336,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
||||||
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
|
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
|
||||||
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
|
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
|
||||||
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState
|
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
|
||||||
|
ExecutionId
|
||||||
FROM AuditLog
|
FROM AuditLog
|
||||||
WHERE ForwardState = $pending
|
WHERE ForwardState = $pending
|
||||||
ORDER BY OccurredAtUtc ASC, EventId ASC
|
ORDER BY OccurredAtUtc ASC, EventId ASC
|
||||||
@@ -379,7 +385,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
||||||
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
|
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
|
||||||
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
|
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
|
||||||
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState
|
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
|
||||||
|
ExecutionId
|
||||||
FROM AuditLog
|
FROM AuditLog
|
||||||
WHERE ForwardState = $forwarded
|
WHERE ForwardState = $forwarded
|
||||||
ORDER BY OccurredAtUtc ASC, EventId ASC
|
ORDER BY OccurredAtUtc ASC, EventId ASC
|
||||||
@@ -465,7 +472,8 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
|
||||||
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
|
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target,
|
||||||
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
|
Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail,
|
||||||
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState
|
RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState,
|
||||||
|
ExecutionId
|
||||||
FROM AuditLog
|
FROM AuditLog
|
||||||
WHERE ForwardState IN ($pending, $forwarded)
|
WHERE ForwardState IN ($pending, $forwarded)
|
||||||
AND OccurredAtUtc >= $since
|
AND OccurredAtUtc >= $since
|
||||||
@@ -642,6 +650,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
|
|||||||
PayloadTruncated = reader.GetInt32(17) != 0,
|
PayloadTruncated = reader.GetInt32(17) != 0,
|
||||||
Extra = reader.IsDBNull(18) ? null : reader.GetString(18),
|
Extra = reader.IsDBNull(18) ? null : reader.GetString(18),
|
||||||
ForwardState = Enum.Parse<AuditForwardState>(reader.GetString(19)),
|
ForwardState = Enum.Parse<AuditForwardState>(reader.GetString(19)),
|
||||||
|
ExecutionId = reader.IsDBNull(20) ? null : Guid.Parse(reader.GetString(20)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ public sealed record AuditEvent
|
|||||||
/// <summary>Correlation id linking related audit rows (e.g. the cached-op lifecycle).</summary>
|
/// <summary>Correlation id linking related audit rows (e.g. the cached-op lifecycle).</summary>
|
||||||
public Guid? CorrelationId { get; init; }
|
public Guid? CorrelationId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Id of the originating script execution / inbound request — the universal
|
||||||
|
/// per-run correlation value, distinct from <see cref="CorrelationId"/> (which
|
||||||
|
/// is the per-operation lifecycle id).
|
||||||
|
/// </summary>
|
||||||
|
public Guid? ExecutionId { get; init; }
|
||||||
|
|
||||||
/// <summary>Site id where the action originated; null for central-direct events.</summary>
|
/// <summary>Site id where the action originated; null for central-direct events.</summary>
|
||||||
public string? SourceSiteId { get; init; }
|
public string? SourceSiteId { get; init; }
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ namespace ScadaLink.Commons.Types.Audit;
|
|||||||
/// dimension (translated to a SQL <c>IN (…)</c>). Time bounds are half-open in
|
/// dimension (translated to a SQL <c>IN (…)</c>). Time bounds are half-open in
|
||||||
/// the spec sense — <see cref="FromUtc"/> is inclusive and <see cref="ToUtc"/> is
|
/// 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>
|
/// inclusive of the upper bound; the repository SQL uses <c>>=</c> / <c><=</c>
|
||||||
/// respectively. All filter dimensions are AND-combined with one another.
|
/// respectively. All filter dimensions are AND-combined with one another. The
|
||||||
|
/// single-value <see cref="CorrelationId"/> and <see cref="ExecutionId"/>
|
||||||
|
/// dimensions constrain on equality when set.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record AuditLogQueryFilter(
|
public sealed record AuditLogQueryFilter(
|
||||||
IReadOnlyList<AuditChannel>? Channels = null,
|
IReadOnlyList<AuditChannel>? Channels = null,
|
||||||
@@ -21,5 +23,6 @@ public sealed record AuditLogQueryFilter(
|
|||||||
string? Target = null,
|
string? Target = null,
|
||||||
string? Actor = null,
|
string? Actor = null,
|
||||||
Guid? CorrelationId = null,
|
Guid? CorrelationId = null,
|
||||||
|
Guid? ExecutionId = null,
|
||||||
DateTime? FromUtc = null,
|
DateTime? FromUtc = null,
|
||||||
DateTime? ToUtc = null);
|
DateTime? ToUtc = null);
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ public static class AuditEventDtoMapper
|
|||||||
Channel = evt.Channel.ToString(),
|
Channel = evt.Channel.ToString(),
|
||||||
Kind = evt.Kind.ToString(),
|
Kind = evt.Kind.ToString(),
|
||||||
CorrelationId = evt.CorrelationId?.ToString() ?? string.Empty,
|
CorrelationId = evt.CorrelationId?.ToString() ?? string.Empty,
|
||||||
|
ExecutionId = evt.ExecutionId?.ToString() ?? string.Empty,
|
||||||
SourceSiteId = evt.SourceSiteId ?? string.Empty,
|
SourceSiteId = evt.SourceSiteId ?? string.Empty,
|
||||||
SourceInstanceId = evt.SourceInstanceId ?? string.Empty,
|
SourceInstanceId = evt.SourceInstanceId ?? string.Empty,
|
||||||
SourceScript = evt.SourceScript ?? string.Empty,
|
SourceScript = evt.SourceScript ?? string.Empty,
|
||||||
@@ -92,6 +93,7 @@ public static class AuditEventDtoMapper
|
|||||||
Channel = Enum.Parse<AuditChannel>(dto.Channel),
|
Channel = Enum.Parse<AuditChannel>(dto.Channel),
|
||||||
Kind = Enum.Parse<AuditKind>(dto.Kind),
|
Kind = Enum.Parse<AuditKind>(dto.Kind),
|
||||||
CorrelationId = NullIfEmpty(dto.CorrelationId) is { } cid ? Guid.Parse(cid) : null,
|
CorrelationId = NullIfEmpty(dto.CorrelationId) is { } cid ? Guid.Parse(cid) : null,
|
||||||
|
ExecutionId = NullIfEmpty(dto.ExecutionId) is { } eid ? Guid.Parse(eid) : null,
|
||||||
SourceSiteId = NullIfEmpty(dto.SourceSiteId),
|
SourceSiteId = NullIfEmpty(dto.SourceSiteId),
|
||||||
SourceInstanceId = NullIfEmpty(dto.SourceInstanceId),
|
SourceInstanceId = NullIfEmpty(dto.SourceInstanceId),
|
||||||
SourceScript = NullIfEmpty(dto.SourceScript),
|
SourceScript = NullIfEmpty(dto.SourceScript),
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ message AuditEventDto {
|
|||||||
string response_summary = 17;
|
string response_summary = 17;
|
||||||
bool payload_truncated = 18;
|
bool payload_truncated = 18;
|
||||||
string extra = 19;
|
string extra = 19;
|
||||||
|
string execution_id = 20; // empty string represents null
|
||||||
}
|
}
|
||||||
|
|
||||||
message AuditEventBatch { repeated AuditEventDto events = 1; }
|
message AuditEventBatch { repeated AuditEventDto events = 1; }
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ namespace ScadaLink.Communication.Grpc {
|
|||||||
"c3RhdGUYAyABKA4yGi5zaXRlc3RyZWFtLkFsYXJtU3RhdGVFbnVtEhAKCHBy",
|
"c3RhdGUYAyABKA4yGi5zaXRlc3RyZWFtLkFsYXJtU3RhdGVFbnVtEhAKCHBy",
|
||||||
"aW9yaXR5GAQgASgFEi0KCXRpbWVzdGFtcBgFIAEoCzIaLmdvb2dsZS5wcm90",
|
"aW9yaXR5GAQgASgFEi0KCXRpbWVzdGFtcBgFIAEoCzIaLmdvb2dsZS5wcm90",
|
||||||
"b2J1Zi5UaW1lc3RhbXASKQoFbGV2ZWwYBiABKA4yGi5zaXRlc3RyZWFtLkFs",
|
"b2J1Zi5UaW1lc3RhbXASKQoFbGV2ZWwYBiABKA4yGi5zaXRlc3RyZWFtLkFs",
|
||||||
"YXJtTGV2ZWxFbnVtEg8KB21lc3NhZ2UYByABKAki9QMKDUF1ZGl0RXZlbnRE",
|
"YXJtTGV2ZWxFbnVtEg8KB21lc3NhZ2UYByABKAkiiwQKDUF1ZGl0RXZlbnRE",
|
||||||
"dG8SEAoIZXZlbnRfaWQYASABKAkSMwoPb2NjdXJyZWRfYXRfdXRjGAIgASgL",
|
"dG8SEAoIZXZlbnRfaWQYASABKAkSMwoPb2NjdXJyZWRfYXRfdXRjGAIgASgL",
|
||||||
"MhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIPCgdjaGFubmVsGAMgASgJ",
|
"MhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIPCgdjaGFubmVsGAMgASgJ",
|
||||||
"EgwKBGtpbmQYBCABKAkSFgoOY29ycmVsYXRpb25faWQYBSABKAkSFgoOc291",
|
"EgwKBGtpbmQYBCABKAkSFgoOY29ycmVsYXRpb25faWQYBSABKAkSFgoOc291",
|
||||||
@@ -52,43 +52,43 @@ namespace ScadaLink.Communication.Grpc {
|
|||||||
"GA0gASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDMyVmFsdWUSFQoNZXJyb3Jf",
|
"GA0gASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDMyVmFsdWUSFQoNZXJyb3Jf",
|
||||||
"bWVzc2FnZRgOIAEoCRIUCgxlcnJvcl9kZXRhaWwYDyABKAkSFwoPcmVxdWVz",
|
"bWVzc2FnZRgOIAEoCRIUCgxlcnJvcl9kZXRhaWwYDyABKAkSFwoPcmVxdWVz",
|
||||||
"dF9zdW1tYXJ5GBAgASgJEhgKEHJlc3BvbnNlX3N1bW1hcnkYESABKAkSGQoR",
|
"dF9zdW1tYXJ5GBAgASgJEhgKEHJlc3BvbnNlX3N1bW1hcnkYESABKAkSGQoR",
|
||||||
"cGF5bG9hZF90cnVuY2F0ZWQYEiABKAgSDQoFZXh0cmEYEyABKAkiPAoPQXVk",
|
"cGF5bG9hZF90cnVuY2F0ZWQYEiABKAgSDQoFZXh0cmEYEyABKAkSFAoMZXhl",
|
||||||
"aXRFdmVudEJhdGNoEikKBmV2ZW50cxgBIAMoCzIZLnNpdGVzdHJlYW0uQXVk",
|
"Y3V0aW9uX2lkGBQgASgJIjwKD0F1ZGl0RXZlbnRCYXRjaBIpCgZldmVudHMY",
|
||||||
"aXRFdmVudER0byInCglJbmdlc3RBY2sSGgoSYWNjZXB0ZWRfZXZlbnRfaWRz",
|
"ASADKAsyGS5zaXRlc3RyZWFtLkF1ZGl0RXZlbnREdG8iJwoJSW5nZXN0QWNr",
|
||||||
"GAEgAygJIvQCChZTaXRlQ2FsbE9wZXJhdGlvbmFsRHRvEhwKFHRyYWNrZWRf",
|
"EhoKEmFjY2VwdGVkX2V2ZW50X2lkcxgBIAMoCSL0AgoWU2l0ZUNhbGxPcGVy",
|
||||||
"b3BlcmF0aW9uX2lkGAEgASgJEg8KB2NoYW5uZWwYAiABKAkSDgoGdGFyZ2V0",
|
"YXRpb25hbER0bxIcChR0cmFja2VkX29wZXJhdGlvbl9pZBgBIAEoCRIPCgdj",
|
||||||
"GAMgASgJEhMKC3NvdXJjZV9zaXRlGAQgASgJEg4KBnN0YXR1cxgFIAEoCRIT",
|
"aGFubmVsGAIgASgJEg4KBnRhcmdldBgDIAEoCRITCgtzb3VyY2Vfc2l0ZRgE",
|
||||||
"CgtyZXRyeV9jb3VudBgGIAEoBRISCgpsYXN0X2Vycm9yGAcgASgJEjAKC2h0",
|
"IAEoCRIOCgZzdGF0dXMYBSABKAkSEwoLcmV0cnlfY291bnQYBiABKAUSEgoK",
|
||||||
"dHBfc3RhdHVzGAggASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDMyVmFsdWUS",
|
"bGFzdF9lcnJvchgHIAEoCRIwCgtodHRwX3N0YXR1cxgIIAEoCzIbLmdvb2ds",
|
||||||
"MgoOY3JlYXRlZF9hdF91dGMYCSABKAsyGi5nb29nbGUucHJvdG9idWYuVGlt",
|
"ZS5wcm90b2J1Zi5JbnQzMlZhbHVlEjIKDmNyZWF0ZWRfYXRfdXRjGAkgASgL",
|
||||||
"ZXN0YW1wEjIKDnVwZGF0ZWRfYXRfdXRjGAogASgLMhouZ29vZ2xlLnByb3Rv",
|
"MhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIyCg51cGRhdGVkX2F0X3V0",
|
||||||
"YnVmLlRpbWVzdGFtcBIzCg90ZXJtaW5hbF9hdF91dGMYCyABKAsyGi5nb29n",
|
"YxgKIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASMwoPdGVybWlu",
|
||||||
"bGUucHJvdG9idWYuVGltZXN0YW1wIoABChVDYWNoZWRUZWxlbWV0cnlQYWNr",
|
"YWxfYXRfdXRjGAsgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKA",
|
||||||
"ZXQSLgoLYXVkaXRfZXZlbnQYASABKAsyGS5zaXRlc3RyZWFtLkF1ZGl0RXZl",
|
"AQoVQ2FjaGVkVGVsZW1ldHJ5UGFja2V0Ei4KC2F1ZGl0X2V2ZW50GAEgASgL",
|
||||||
"bnREdG8SNwoLb3BlcmF0aW9uYWwYAiABKAsyIi5zaXRlc3RyZWFtLlNpdGVD",
|
"Mhkuc2l0ZXN0cmVhbS5BdWRpdEV2ZW50RHRvEjcKC29wZXJhdGlvbmFsGAIg",
|
||||||
"YWxsT3BlcmF0aW9uYWxEdG8iSgoUQ2FjaGVkVGVsZW1ldHJ5QmF0Y2gSMgoH",
|
"ASgLMiIuc2l0ZXN0cmVhbS5TaXRlQ2FsbE9wZXJhdGlvbmFsRHRvIkoKFENh",
|
||||||
"cGFja2V0cxgBIAMoCzIhLnNpdGVzdHJlYW0uQ2FjaGVkVGVsZW1ldHJ5UGFj",
|
"Y2hlZFRlbGVtZXRyeUJhdGNoEjIKB3BhY2tldHMYASADKAsyIS5zaXRlc3Ry",
|
||||||
"a2V0IlsKFlB1bGxBdWRpdEV2ZW50c1JlcXVlc3QSLQoJc2luY2VfdXRjGAEg",
|
"ZWFtLkNhY2hlZFRlbGVtZXRyeVBhY2tldCJbChZQdWxsQXVkaXRFdmVudHNS",
|
||||||
"ASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBISCgpiYXRjaF9zaXpl",
|
"ZXF1ZXN0Ei0KCXNpbmNlX3V0YxgBIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5U",
|
||||||
"GAIgASgFIlwKF1B1bGxBdWRpdEV2ZW50c1Jlc3BvbnNlEikKBmV2ZW50cxgB",
|
"aW1lc3RhbXASEgoKYmF0Y2hfc2l6ZRgCIAEoBSJcChdQdWxsQXVkaXRFdmVu",
|
||||||
"IAMoCzIZLnNpdGVzdHJlYW0uQXVkaXRFdmVudER0bxIWCg5tb3JlX2F2YWls",
|
"dHNSZXNwb25zZRIpCgZldmVudHMYASADKAsyGS5zaXRlc3RyZWFtLkF1ZGl0",
|
||||||
"YWJsZRgCIAEoCCpcCgdRdWFsaXR5EhcKE1FVQUxJVFlfVU5TUEVDSUZJRUQQ",
|
"RXZlbnREdG8SFgoObW9yZV9hdmFpbGFibGUYAiABKAgqXAoHUXVhbGl0eRIX",
|
||||||
"ABIQCgxRVUFMSVRZX0dPT0QQARIVChFRVUFMSVRZX1VOQ0VSVEFJThACEg8K",
|
"ChNRVUFMSVRZX1VOU1BFQ0lGSUVEEAASEAoMUVVBTElUWV9HT09EEAESFQoR",
|
||||||
"C1FVQUxJVFlfQkFEEAMqXQoOQWxhcm1TdGF0ZUVudW0SGwoXQUxBUk1fU1RB",
|
"UVVBTElUWV9VTkNFUlRBSU4QAhIPCgtRVUFMSVRZX0JBRBADKl0KDkFsYXJt",
|
||||||
"VEVfVU5TUEVDSUZJRUQQABIWChJBTEFSTV9TVEFURV9OT1JNQUwQARIWChJB",
|
"U3RhdGVFbnVtEhsKF0FMQVJNX1NUQVRFX1VOU1BFQ0lGSUVEEAASFgoSQUxB",
|
||||||
"TEFSTV9TVEFURV9BQ1RJVkUQAiqFAQoOQWxhcm1MZXZlbEVudW0SFAoQQUxB",
|
"Uk1fU1RBVEVfTk9STUFMEAESFgoSQUxBUk1fU1RBVEVfQUNUSVZFEAIqhQEK",
|
||||||
"Uk1fTEVWRUxfTk9ORRAAEhMKD0FMQVJNX0xFVkVMX0xPVxABEhcKE0FMQVJN",
|
"DkFsYXJtTGV2ZWxFbnVtEhQKEEFMQVJNX0xFVkVMX05PTkUQABITCg9BTEFS",
|
||||||
"X0xFVkVMX0xPV19MT1cQAhIUChBBTEFSTV9MRVZFTF9ISUdIEAMSGQoVQUxB",
|
"TV9MRVZFTF9MT1cQARIXChNBTEFSTV9MRVZFTF9MT1dfTE9XEAISFAoQQUxB",
|
||||||
"Uk1fTEVWRUxfSElHSF9ISUdIEAQy4QIKEVNpdGVTdHJlYW1TZXJ2aWNlElUK",
|
"Uk1fTEVWRUxfSElHSBADEhkKFUFMQVJNX0xFVkVMX0hJR0hfSElHSBAEMuEC",
|
||||||
"EVN1YnNjcmliZUluc3RhbmNlEiEuc2l0ZXN0cmVhbS5JbnN0YW5jZVN0cmVh",
|
"ChFTaXRlU3RyZWFtU2VydmljZRJVChFTdWJzY3JpYmVJbnN0YW5jZRIhLnNp",
|
||||||
"bVJlcXVlc3QaGy5zaXRlc3RyZWFtLlNpdGVTdHJlYW1FdmVudDABEkcKEUlu",
|
"dGVzdHJlYW0uSW5zdGFuY2VTdHJlYW1SZXF1ZXN0Ghsuc2l0ZXN0cmVhbS5T",
|
||||||
"Z2VzdEF1ZGl0RXZlbnRzEhsuc2l0ZXN0cmVhbS5BdWRpdEV2ZW50QmF0Y2ga",
|
"aXRlU3RyZWFtRXZlbnQwARJHChFJbmdlc3RBdWRpdEV2ZW50cxIbLnNpdGVz",
|
||||||
"FS5zaXRlc3RyZWFtLkluZ2VzdEFjaxJQChVJbmdlc3RDYWNoZWRUZWxlbWV0",
|
"dHJlYW0uQXVkaXRFdmVudEJhdGNoGhUuc2l0ZXN0cmVhbS5Jbmdlc3RBY2sS",
|
||||||
"cnkSIC5zaXRlc3RyZWFtLkNhY2hlZFRlbGVtZXRyeUJhdGNoGhUuc2l0ZXN0",
|
"UAoVSW5nZXN0Q2FjaGVkVGVsZW1ldHJ5EiAuc2l0ZXN0cmVhbS5DYWNoZWRU",
|
||||||
"cmVhbS5Jbmdlc3RBY2sSWgoPUHVsbEF1ZGl0RXZlbnRzEiIuc2l0ZXN0cmVh",
|
"ZWxlbWV0cnlCYXRjaBoVLnNpdGVzdHJlYW0uSW5nZXN0QWNrEloKD1B1bGxB",
|
||||||
"bS5QdWxsQXVkaXRFdmVudHNSZXF1ZXN0GiMuc2l0ZXN0cmVhbS5QdWxsQXVk",
|
"dWRpdEV2ZW50cxIiLnNpdGVzdHJlYW0uUHVsbEF1ZGl0RXZlbnRzUmVxdWVz",
|
||||||
"aXRFdmVudHNSZXNwb25zZUIfqgIcU2NhZGFMaW5rLkNvbW11bmljYXRpb24u",
|
"dBojLnNpdGVzdHJlYW0uUHVsbEF1ZGl0RXZlbnRzUmVzcG9uc2VCH6oCHFNj",
|
||||||
"R3JwY2IGcHJvdG8z"));
|
"YWRhTGluay5Db21tdW5pY2F0aW9uLkdycGNiBnByb3RvMw=="));
|
||||||
descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData,
|
descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData,
|
||||||
new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.WrappersReflection.Descriptor, },
|
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[] {
|
new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ScadaLink.Communication.Grpc.Quality), typeof(global::ScadaLink.Communication.Grpc.AlarmStateEnum), typeof(global::ScadaLink.Communication.Grpc.AlarmLevelEnum), }, null, new pbr::GeneratedClrTypeInfo[] {
|
||||||
@@ -96,7 +96,7 @@ namespace ScadaLink.Communication.Grpc {
|
|||||||
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.SiteStreamEvent), global::ScadaLink.Communication.Grpc.SiteStreamEvent.Parser, new[]{ "CorrelationId", "AttributeChanged", "AlarmChanged" }, new[]{ "Event" }, null, null, null),
|
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.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.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.AlarmStateUpdate), global::ScadaLink.Communication.Grpc.AlarmStateUpdate.Parser, new[]{ "InstanceUniqueName", "AlarmName", "State", "Priority", "Timestamp", "Level", "Message" }, null, null, null, null),
|
||||||
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AuditEventDto), global::ScadaLink.Communication.Grpc.AuditEventDto.Parser, new[]{ "EventId", "OccurredAtUtc", "Channel", "Kind", "CorrelationId", "SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target", "Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra" }, null, null, null, null),
|
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AuditEventDto), global::ScadaLink.Communication.Grpc.AuditEventDto.Parser, new[]{ "EventId", "OccurredAtUtc", "Channel", "Kind", "CorrelationId", "SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target", "Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra", "ExecutionId" }, null, null, null, null),
|
||||||
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AuditEventBatch), global::ScadaLink.Communication.Grpc.AuditEventBatch.Parser, new[]{ "Events" }, null, null, null, null),
|
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.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.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),
|
new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.SiteCallOperationalDto), global::ScadaLink.Communication.Grpc.SiteCallOperationalDto.Parser, new[]{ "TrackedOperationId", "Channel", "Target", "SourceSite", "Status", "RetryCount", "LastError", "HttpStatus", "CreatedAtUtc", "UpdatedAtUtc", "TerminalAtUtc" }, null, null, null, null),
|
||||||
@@ -1591,6 +1591,7 @@ namespace ScadaLink.Communication.Grpc {
|
|||||||
responseSummary_ = other.responseSummary_;
|
responseSummary_ = other.responseSummary_;
|
||||||
payloadTruncated_ = other.payloadTruncated_;
|
payloadTruncated_ = other.payloadTruncated_;
|
||||||
extra_ = other.extra_;
|
extra_ = other.extra_;
|
||||||
|
executionId_ = other.executionId_;
|
||||||
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
|
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1838,6 +1839,21 @@ namespace ScadaLink.Communication.Grpc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Field number for the "execution_id" field.</summary>
|
||||||
|
public const int ExecutionIdFieldNumber = 20;
|
||||||
|
private string executionId_ = "";
|
||||||
|
/// <summary>
|
||||||
|
/// empty string represents null
|
||||||
|
/// </summary>
|
||||||
|
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||||
|
public string ExecutionId {
|
||||||
|
get { return executionId_; }
|
||||||
|
set {
|
||||||
|
executionId_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||||
public override bool Equals(object other) {
|
public override bool Equals(object other) {
|
||||||
@@ -1872,6 +1888,7 @@ namespace ScadaLink.Communication.Grpc {
|
|||||||
if (ResponseSummary != other.ResponseSummary) return false;
|
if (ResponseSummary != other.ResponseSummary) return false;
|
||||||
if (PayloadTruncated != other.PayloadTruncated) return false;
|
if (PayloadTruncated != other.PayloadTruncated) return false;
|
||||||
if (Extra != other.Extra) return false;
|
if (Extra != other.Extra) return false;
|
||||||
|
if (ExecutionId != other.ExecutionId) return false;
|
||||||
return Equals(_unknownFields, other._unknownFields);
|
return Equals(_unknownFields, other._unknownFields);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1898,6 +1915,7 @@ namespace ScadaLink.Communication.Grpc {
|
|||||||
if (ResponseSummary.Length != 0) hash ^= ResponseSummary.GetHashCode();
|
if (ResponseSummary.Length != 0) hash ^= ResponseSummary.GetHashCode();
|
||||||
if (PayloadTruncated != false) hash ^= PayloadTruncated.GetHashCode();
|
if (PayloadTruncated != false) hash ^= PayloadTruncated.GetHashCode();
|
||||||
if (Extra.Length != 0) hash ^= Extra.GetHashCode();
|
if (Extra.Length != 0) hash ^= Extra.GetHashCode();
|
||||||
|
if (ExecutionId.Length != 0) hash ^= ExecutionId.GetHashCode();
|
||||||
if (_unknownFields != null) {
|
if (_unknownFields != null) {
|
||||||
hash ^= _unknownFields.GetHashCode();
|
hash ^= _unknownFields.GetHashCode();
|
||||||
}
|
}
|
||||||
@@ -1990,6 +2008,10 @@ namespace ScadaLink.Communication.Grpc {
|
|||||||
output.WriteRawTag(154, 1);
|
output.WriteRawTag(154, 1);
|
||||||
output.WriteString(Extra);
|
output.WriteString(Extra);
|
||||||
}
|
}
|
||||||
|
if (ExecutionId.Length != 0) {
|
||||||
|
output.WriteRawTag(162, 1);
|
||||||
|
output.WriteString(ExecutionId);
|
||||||
|
}
|
||||||
if (_unknownFields != null) {
|
if (_unknownFields != null) {
|
||||||
_unknownFields.WriteTo(output);
|
_unknownFields.WriteTo(output);
|
||||||
}
|
}
|
||||||
@@ -2074,6 +2096,10 @@ namespace ScadaLink.Communication.Grpc {
|
|||||||
output.WriteRawTag(154, 1);
|
output.WriteRawTag(154, 1);
|
||||||
output.WriteString(Extra);
|
output.WriteString(Extra);
|
||||||
}
|
}
|
||||||
|
if (ExecutionId.Length != 0) {
|
||||||
|
output.WriteRawTag(162, 1);
|
||||||
|
output.WriteString(ExecutionId);
|
||||||
|
}
|
||||||
if (_unknownFields != null) {
|
if (_unknownFields != null) {
|
||||||
_unknownFields.WriteTo(ref output);
|
_unknownFields.WriteTo(ref output);
|
||||||
}
|
}
|
||||||
@@ -2141,6 +2167,9 @@ namespace ScadaLink.Communication.Grpc {
|
|||||||
if (Extra.Length != 0) {
|
if (Extra.Length != 0) {
|
||||||
size += 2 + pb::CodedOutputStream.ComputeStringSize(Extra);
|
size += 2 + pb::CodedOutputStream.ComputeStringSize(Extra);
|
||||||
}
|
}
|
||||||
|
if (ExecutionId.Length != 0) {
|
||||||
|
size += 2 + pb::CodedOutputStream.ComputeStringSize(ExecutionId);
|
||||||
|
}
|
||||||
if (_unknownFields != null) {
|
if (_unknownFields != null) {
|
||||||
size += _unknownFields.CalculateSize();
|
size += _unknownFields.CalculateSize();
|
||||||
}
|
}
|
||||||
@@ -2217,6 +2246,9 @@ namespace ScadaLink.Communication.Grpc {
|
|||||||
if (other.Extra.Length != 0) {
|
if (other.Extra.Length != 0) {
|
||||||
Extra = other.Extra;
|
Extra = other.Extra;
|
||||||
}
|
}
|
||||||
|
if (other.ExecutionId.Length != 0) {
|
||||||
|
ExecutionId = other.ExecutionId;
|
||||||
|
}
|
||||||
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
|
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2321,6 +2353,10 @@ namespace ScadaLink.Communication.Grpc {
|
|||||||
Extra = input.ReadString();
|
Extra = input.ReadString();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 162: {
|
||||||
|
ExecutionId = input.ReadString();
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -2425,6 +2461,10 @@ namespace ScadaLink.Communication.Grpc {
|
|||||||
Extra = input.ReadString();
|
Extra = input.ReadString();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 162: {
|
||||||
|
ExecutionId = input.ReadString();
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditEve
|
|||||||
.HasFilter("[CorrelationId] IS NOT NULL")
|
.HasFilter("[CorrelationId] IS NOT NULL")
|
||||||
.HasDatabaseName("IX_AuditLog_CorrelationId");
|
.HasDatabaseName("IX_AuditLog_CorrelationId");
|
||||||
|
|
||||||
|
builder.HasIndex(e => e.ExecutionId)
|
||||||
|
.HasFilter("[ExecutionId] IS NOT NULL")
|
||||||
|
.HasDatabaseName("IX_AuditLog_Execution");
|
||||||
|
|
||||||
builder.HasIndex(e => new { e.Channel, e.Status, e.OccurredAtUtc })
|
builder.HasIndex(e => new { e.Channel, e.Status, e.OccurredAtUtc })
|
||||||
.IsDescending(false, false, true)
|
.IsDescending(false, false, true)
|
||||||
.HasDatabaseName("IX_AuditLog_Channel_Status_Occurred");
|
.HasDatabaseName("IX_AuditLog_Channel_Status_Occurred");
|
||||||
|
|||||||
Generated
+1626
File diff suppressed because it is too large
Load Diff
+57
@@ -0,0 +1,57 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the universal <c>ExecutionId</c> correlation column to the centralized
|
||||||
|
/// <c>AuditLog</c> table (#23). <c>ExecutionId</c> identifies the originating
|
||||||
|
/// script execution / inbound request and is distinct from the per-operation
|
||||||
|
/// <c>CorrelationId</c>.
|
||||||
|
///
|
||||||
|
/// The change is purely additive:
|
||||||
|
/// 1. <c>ExecutionId uniqueidentifier NULL</c> is added with no default, so the
|
||||||
|
/// operation is a metadata-only <c>ALTER TABLE … ADD</c> — it does NOT
|
||||||
|
/// rewrite the monthly-partitioned <c>AuditLog</c> table, and historical
|
||||||
|
/// rows stay <c>NULL</c> (no backfill).
|
||||||
|
/// 2. <c>IX_AuditLog_Execution</c> is created via raw SQL so it lands on the
|
||||||
|
/// <c>ps_AuditLog_Month(OccurredAtUtc)</c> partition scheme, matching every
|
||||||
|
/// other <c>IX_AuditLog_*</c> index. Keeping it partition-aligned preserves
|
||||||
|
/// the partition-switch purge path (see AuditLogRepository.SwitchOutPartitionAsync).
|
||||||
|
/// </summary>
|
||||||
|
public partial class AddAuditLogExecutionId : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "ExecutionId",
|
||||||
|
table: "AuditLog",
|
||||||
|
type: "uniqueidentifier",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
// Raw SQL so the index is created on the partition scheme — EF's
|
||||||
|
// CreateIndex cannot express the ON ps_AuditLog_Month(OccurredAtUtc)
|
||||||
|
// clause. Mirrors IX_AuditLog_CorrelationId (filtered, aligned).
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
CREATE NONCLUSTERED INDEX IX_AuditLog_Execution
|
||||||
|
ON dbo.AuditLog (ExecutionId)
|
||||||
|
WHERE ExecutionId IS NOT NULL
|
||||||
|
ON ps_AuditLog_Month(OccurredAtUtc);");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_Execution' AND object_id = OBJECT_ID('dbo.AuditLog'))
|
||||||
|
DROP INDEX IX_AuditLog_Execution ON dbo.AuditLog;");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ExecutionId",
|
||||||
|
table: "AuditLog");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,6 +73,9 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
|
|||||||
.HasMaxLength(1024)
|
.HasMaxLength(1024)
|
||||||
.HasColumnType("nvarchar(1024)");
|
.HasColumnType("nvarchar(1024)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ExecutionId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
b.Property<string>("Extra")
|
b.Property<string>("Extra")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -138,6 +141,10 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
|
|||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasDatabaseName("UX_AuditLog_EventId");
|
.HasDatabaseName("UX_AuditLog_EventId");
|
||||||
|
|
||||||
|
b.HasIndex("ExecutionId")
|
||||||
|
.HasDatabaseName("IX_AuditLog_Execution")
|
||||||
|
.HasFilter("[ExecutionId] IS NOT NULL");
|
||||||
|
|
||||||
b.HasIndex("OccurredAtUtc")
|
b.HasIndex("OccurredAtUtc")
|
||||||
.IsDescending()
|
.IsDescending()
|
||||||
.HasDatabaseName("IX_AuditLog_OccurredAtUtc");
|
.HasDatabaseName("IX_AuditLog_OccurredAtUtc");
|
||||||
|
|||||||
@@ -64,12 +64,12 @@ public class AuditLogRepository : IAuditLogRepository
|
|||||||
await _context.Database.ExecuteSqlInterpolatedAsync(
|
await _context.Database.ExecuteSqlInterpolatedAsync(
|
||||||
$@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId})
|
$@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId})
|
||||||
INSERT INTO dbo.AuditLog
|
INSERT INTO dbo.AuditLog
|
||||||
(EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId,
|
(EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId, ExecutionId,
|
||||||
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status,
|
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status,
|
||||||
HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary,
|
HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary,
|
||||||
ResponseSummary, PayloadTruncated, Extra, ForwardState)
|
ResponseSummary, PayloadTruncated, Extra, ForwardState)
|
||||||
VALUES
|
VALUES
|
||||||
({evt.EventId}, {evt.OccurredAtUtc}, {evt.IngestedAtUtc}, {channel}, {kind}, {evt.CorrelationId},
|
({evt.EventId}, {evt.OccurredAtUtc}, {evt.IngestedAtUtc}, {channel}, {kind}, {evt.CorrelationId}, {evt.ExecutionId},
|
||||||
{evt.SourceSiteId}, {evt.SourceInstanceId}, {evt.SourceScript}, {evt.Actor}, {evt.Target}, {status},
|
{evt.SourceSiteId}, {evt.SourceInstanceId}, {evt.SourceScript}, {evt.Actor}, {evt.Target}, {status},
|
||||||
{evt.HttpStatus}, {evt.DurationMs}, {evt.ErrorMessage}, {evt.ErrorDetail}, {evt.RequestSummary},
|
{evt.HttpStatus}, {evt.DurationMs}, {evt.ErrorMessage}, {evt.ErrorDetail}, {evt.RequestSummary},
|
||||||
{evt.ResponseSummary}, {evt.PayloadTruncated}, {evt.Extra}, {forwardState});",
|
{evt.ResponseSummary}, {evt.PayloadTruncated}, {evt.Extra}, {forwardState});",
|
||||||
@@ -157,6 +157,11 @@ VALUES
|
|||||||
query = query.Where(e => e.CorrelationId == correlationId);
|
query = query.Where(e => e.CorrelationId == correlationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filter.ExecutionId is { } executionId)
|
||||||
|
{
|
||||||
|
query = query.Where(e => e.ExecutionId == executionId);
|
||||||
|
}
|
||||||
|
|
||||||
if (filter.FromUtc is { } fromUtc)
|
if (filter.FromUtc is { } fromUtc)
|
||||||
{
|
{
|
||||||
query = query.Where(e => e.OccurredAtUtc >= fromUtc);
|
query = query.Where(e => e.OccurredAtUtc >= fromUtc);
|
||||||
@@ -263,6 +268,10 @@ VALUES
|
|||||||
PayloadTruncated bit NOT NULL,
|
PayloadTruncated bit NOT NULL,
|
||||||
Extra nvarchar(max) NULL,
|
Extra nvarchar(max) NULL,
|
||||||
ForwardState varchar(32) NULL,
|
ForwardState varchar(32) NULL,
|
||||||
|
-- ExecutionId is last because it was added to the live AuditLog table by a later
|
||||||
|
-- ALTER TABLE ADD migration; the staging table must match the live table column
|
||||||
|
-- shape ordinal-for-ordinal or ALTER TABLE ... SWITCH PARTITION fails.
|
||||||
|
ExecutionId uniqueidentifier NULL,
|
||||||
CONSTRAINT PK_{stagingTableName} PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc)
|
CONSTRAINT PK_{stagingTableName} PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc)
|
||||||
) ON [PRIMARY];
|
) ON [PRIMARY];
|
||||||
|
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ public class SqliteAuditWriterSchemaTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Opens_Creates_AuditLog_Table_With_20Columns_And_PK_On_EventId()
|
public void Opens_Creates_AuditLog_Table_With_21Columns_And_PK_On_EventId()
|
||||||
{
|
{
|
||||||
var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_AuditLog_Table_With_20Columns_And_PK_On_EventId));
|
var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_AuditLog_Table_With_21Columns_And_PK_On_EventId));
|
||||||
using (writer)
|
using (writer)
|
||||||
{
|
{
|
||||||
using var connection = OpenVerifierConnection(dataSource);
|
using var connection = OpenVerifierConnection(dataSource);
|
||||||
@@ -57,7 +57,7 @@ public class SqliteAuditWriterSchemaTests
|
|||||||
columns.Add((reader.GetString(1), reader.GetInt32(5)));
|
columns.Add((reader.GetString(1), reader.GetInt32(5)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Assert.Equal(20, columns.Count);
|
Assert.Equal(21, columns.Count);
|
||||||
|
|
||||||
var expected = new[]
|
var expected = new[]
|
||||||
{
|
{
|
||||||
@@ -65,7 +65,7 @@ public class SqliteAuditWriterSchemaTests
|
|||||||
"SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target",
|
"SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target",
|
||||||
"Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail",
|
"Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail",
|
||||||
"RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra",
|
"RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra",
|
||||||
"ForwardState",
|
"ForwardState", "ExecutionId",
|
||||||
};
|
};
|
||||||
Assert.Equal(expected.OrderBy(n => n), columns.Select(c => c.Name).OrderBy(n => n));
|
Assert.Equal(expected.OrderBy(n => n), columns.Select(c => c.Name).OrderBy(n => n));
|
||||||
|
|
||||||
|
|||||||
@@ -353,4 +353,37 @@ public class SqliteAuditWriterWriteTests
|
|||||||
await writer.MarkReconciledAsync(new[] { Guid.NewGuid(), Guid.NewGuid() });
|
await writer.MarkReconciledAsync(new[] { Guid.NewGuid(), Guid.NewGuid() });
|
||||||
// Completes without throwing.
|
// Completes without throwing.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----- ExecutionId column (universal per-run correlation value) ----- //
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteAsync_NonNullExecutionId_RoundTripsThroughMapRow()
|
||||||
|
{
|
||||||
|
var (writer, _) = CreateWriter(nameof(WriteAsync_NonNullExecutionId_RoundTripsThroughMapRow));
|
||||||
|
await using var _w = writer;
|
||||||
|
|
||||||
|
var executionId = Guid.NewGuid();
|
||||||
|
var evt = NewEvent() with { ExecutionId = executionId };
|
||||||
|
await writer.WriteAsync(evt);
|
||||||
|
|
||||||
|
var rows = await writer.ReadPendingAsync(limit: 10);
|
||||||
|
|
||||||
|
var row = Assert.Single(rows);
|
||||||
|
Assert.Equal(executionId, row.ExecutionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteAsync_NullExecutionId_RoundTripsAsNull()
|
||||||
|
{
|
||||||
|
var (writer, _) = CreateWriter(nameof(WriteAsync_NullExecutionId_RoundTripsAsNull));
|
||||||
|
await using var _w = writer;
|
||||||
|
|
||||||
|
var evt = NewEvent() with { ExecutionId = null };
|
||||||
|
await writer.WriteAsync(evt);
|
||||||
|
|
||||||
|
var rows = await writer.ReadPendingAsync(limit: 10);
|
||||||
|
|
||||||
|
var row = Assert.Single(rows);
|
||||||
|
Assert.Null(row.ExecutionId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public class AuditEventTests
|
|||||||
var occurredAt = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
|
var occurredAt = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
|
||||||
var ingestedAt = new DateTime(2026, 5, 20, 12, 0, 1, DateTimeKind.Utc);
|
var ingestedAt = new DateTime(2026, 5, 20, 12, 0, 1, DateTimeKind.Utc);
|
||||||
var corrId = Guid.NewGuid();
|
var corrId = Guid.NewGuid();
|
||||||
|
var execId = Guid.NewGuid();
|
||||||
|
|
||||||
var evt = new AuditEvent
|
var evt = new AuditEvent
|
||||||
{
|
{
|
||||||
@@ -26,6 +27,7 @@ public class AuditEventTests
|
|||||||
Channel = AuditChannel.ApiOutbound,
|
Channel = AuditChannel.ApiOutbound,
|
||||||
Kind = AuditKind.ApiCall,
|
Kind = AuditKind.ApiCall,
|
||||||
CorrelationId = corrId,
|
CorrelationId = corrId,
|
||||||
|
ExecutionId = execId,
|
||||||
SourceSiteId = "site-01",
|
SourceSiteId = "site-01",
|
||||||
SourceInstanceId = "inst-7",
|
SourceInstanceId = "inst-7",
|
||||||
SourceScript = "OnAlarm",
|
SourceScript = "OnAlarm",
|
||||||
@@ -49,6 +51,7 @@ public class AuditEventTests
|
|||||||
Assert.Equal(AuditChannel.ApiOutbound, evt.Channel);
|
Assert.Equal(AuditChannel.ApiOutbound, evt.Channel);
|
||||||
Assert.Equal(AuditKind.ApiCall, evt.Kind);
|
Assert.Equal(AuditKind.ApiCall, evt.Kind);
|
||||||
Assert.Equal(corrId, evt.CorrelationId);
|
Assert.Equal(corrId, evt.CorrelationId);
|
||||||
|
Assert.Equal(execId, evt.ExecutionId);
|
||||||
Assert.Equal("site-01", evt.SourceSiteId);
|
Assert.Equal("site-01", evt.SourceSiteId);
|
||||||
Assert.Equal("inst-7", evt.SourceInstanceId);
|
Assert.Equal("inst-7", evt.SourceInstanceId);
|
||||||
Assert.Equal("OnAlarm", evt.SourceScript);
|
Assert.Equal("OnAlarm", evt.SourceScript);
|
||||||
@@ -77,6 +80,7 @@ public class AuditEventTests
|
|||||||
Channel = AuditChannel.Notification,
|
Channel = AuditChannel.Notification,
|
||||||
Kind = AuditKind.NotifySend,
|
Kind = AuditKind.NotifySend,
|
||||||
CorrelationId = null,
|
CorrelationId = null,
|
||||||
|
ExecutionId = null,
|
||||||
SourceSiteId = null,
|
SourceSiteId = null,
|
||||||
SourceInstanceId = null,
|
SourceInstanceId = null,
|
||||||
SourceScript = null,
|
SourceScript = null,
|
||||||
@@ -96,6 +100,7 @@ public class AuditEventTests
|
|||||||
|
|
||||||
Assert.Null(evt.IngestedAtUtc);
|
Assert.Null(evt.IngestedAtUtc);
|
||||||
Assert.Null(evt.CorrelationId);
|
Assert.Null(evt.CorrelationId);
|
||||||
|
Assert.Null(evt.ExecutionId);
|
||||||
Assert.Null(evt.SourceSiteId);
|
Assert.Null(evt.SourceSiteId);
|
||||||
Assert.Null(evt.SourceInstanceId);
|
Assert.Null(evt.SourceInstanceId);
|
||||||
Assert.Null(evt.SourceScript);
|
Assert.Null(evt.SourceScript);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public class AuditEventDtoMapperTests
|
|||||||
var occurredAt = new DateTime(2026, 5, 20, 10, 15, 30, 123, DateTimeKind.Utc);
|
var occurredAt = new DateTime(2026, 5, 20, 10, 15, 30, 123, DateTimeKind.Utc);
|
||||||
var ingestedAt = new DateTime(2026, 5, 20, 10, 15, 31, 0, DateTimeKind.Utc);
|
var ingestedAt = new DateTime(2026, 5, 20, 10, 15, 31, 0, DateTimeKind.Utc);
|
||||||
var correlationId = Guid.NewGuid();
|
var correlationId = Guid.NewGuid();
|
||||||
|
var executionId = Guid.NewGuid();
|
||||||
var eventId = Guid.NewGuid();
|
var eventId = Guid.NewGuid();
|
||||||
|
|
||||||
var original = new AuditEvent
|
var original = new AuditEvent
|
||||||
@@ -29,6 +30,7 @@ public class AuditEventDtoMapperTests
|
|||||||
Channel = AuditChannel.ApiOutbound,
|
Channel = AuditChannel.ApiOutbound,
|
||||||
Kind = AuditKind.ApiCallCached,
|
Kind = AuditKind.ApiCallCached,
|
||||||
CorrelationId = correlationId,
|
CorrelationId = correlationId,
|
||||||
|
ExecutionId = executionId,
|
||||||
SourceSiteId = "site-1",
|
SourceSiteId = "site-1",
|
||||||
SourceInstanceId = "Pump01",
|
SourceInstanceId = "Pump01",
|
||||||
SourceScript = "OnDemand",
|
SourceScript = "OnDemand",
|
||||||
@@ -54,6 +56,7 @@ public class AuditEventDtoMapperTests
|
|||||||
Assert.Equal(original.Channel, roundTripped.Channel);
|
Assert.Equal(original.Channel, roundTripped.Channel);
|
||||||
Assert.Equal(original.Kind, roundTripped.Kind);
|
Assert.Equal(original.Kind, roundTripped.Kind);
|
||||||
Assert.Equal(original.CorrelationId, roundTripped.CorrelationId);
|
Assert.Equal(original.CorrelationId, roundTripped.CorrelationId);
|
||||||
|
Assert.Equal(original.ExecutionId, roundTripped.ExecutionId);
|
||||||
Assert.Equal(original.SourceSiteId, roundTripped.SourceSiteId);
|
Assert.Equal(original.SourceSiteId, roundTripped.SourceSiteId);
|
||||||
Assert.Equal(original.SourceInstanceId, roundTripped.SourceInstanceId);
|
Assert.Equal(original.SourceInstanceId, roundTripped.SourceInstanceId);
|
||||||
Assert.Equal(original.SourceScript, roundTripped.SourceScript);
|
Assert.Equal(original.SourceScript, roundTripped.SourceScript);
|
||||||
@@ -90,6 +93,7 @@ public class AuditEventDtoMapperTests
|
|||||||
var dto = AuditEventDtoMapper.ToDto(evt);
|
var dto = AuditEventDtoMapper.ToDto(evt);
|
||||||
|
|
||||||
Assert.Equal(string.Empty, dto.CorrelationId);
|
Assert.Equal(string.Empty, dto.CorrelationId);
|
||||||
|
Assert.Equal(string.Empty, dto.ExecutionId);
|
||||||
Assert.Equal(string.Empty, dto.SourceSiteId);
|
Assert.Equal(string.Empty, dto.SourceSiteId);
|
||||||
Assert.Equal(string.Empty, dto.SourceInstanceId);
|
Assert.Equal(string.Empty, dto.SourceInstanceId);
|
||||||
Assert.Equal(string.Empty, dto.SourceScript);
|
Assert.Equal(string.Empty, dto.SourceScript);
|
||||||
@@ -113,6 +117,7 @@ public class AuditEventDtoMapperTests
|
|||||||
Kind = nameof(AuditKind.ApiCall),
|
Kind = nameof(AuditKind.ApiCall),
|
||||||
Status = nameof(AuditStatus.Submitted),
|
Status = nameof(AuditStatus.Submitted),
|
||||||
CorrelationId = string.Empty,
|
CorrelationId = string.Empty,
|
||||||
|
ExecutionId = string.Empty,
|
||||||
SourceSiteId = string.Empty,
|
SourceSiteId = string.Empty,
|
||||||
SourceInstanceId = string.Empty,
|
SourceInstanceId = string.Empty,
|
||||||
SourceScript = string.Empty,
|
SourceScript = string.Empty,
|
||||||
@@ -128,6 +133,7 @@ public class AuditEventDtoMapperTests
|
|||||||
var evt = AuditEventDtoMapper.FromDto(dto);
|
var evt = AuditEventDtoMapper.FromDto(dto);
|
||||||
|
|
||||||
Assert.Null(evt.CorrelationId);
|
Assert.Null(evt.CorrelationId);
|
||||||
|
Assert.Null(evt.ExecutionId);
|
||||||
Assert.Null(evt.SourceSiteId);
|
Assert.Null(evt.SourceSiteId);
|
||||||
Assert.Null(evt.SourceInstanceId);
|
Assert.Null(evt.SourceInstanceId);
|
||||||
Assert.Null(evt.SourceScript);
|
Assert.Null(evt.SourceScript);
|
||||||
|
|||||||
+10
-3
@@ -74,8 +74,9 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
|
|||||||
.Where(p => !p.IsShadowProperty())
|
.Where(p => !p.IsShadowProperty())
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// AuditEvent record exposes 21 init-only properties (alog.md §4).
|
// AuditEvent record exposes 22 init-only properties (alog.md §4 plus the
|
||||||
Assert.Equal(21, properties.Count);
|
// additive ExecutionId universal correlation column).
|
||||||
|
Assert.Equal(22, properties.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -90,11 +91,13 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
|
|||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// Five reconciliation/query indexes from alog.md §4, plus the EventId unique
|
// Five reconciliation/query indexes from alog.md §4, plus the EventId unique
|
||||||
// index introduced alongside the composite PK (Bundle C).
|
// index introduced alongside the composite PK (Bundle C), plus the additive
|
||||||
|
// IX_AuditLog_Execution index supporting ExecutionId lookups.
|
||||||
var expected = new[]
|
var expected = new[]
|
||||||
{
|
{
|
||||||
"IX_AuditLog_Channel_Status_Occurred",
|
"IX_AuditLog_Channel_Status_Occurred",
|
||||||
"IX_AuditLog_CorrelationId",
|
"IX_AuditLog_CorrelationId",
|
||||||
|
"IX_AuditLog_Execution",
|
||||||
"IX_AuditLog_OccurredAtUtc",
|
"IX_AuditLog_OccurredAtUtc",
|
||||||
"IX_AuditLog_Site_Occurred",
|
"IX_AuditLog_Site_Occurred",
|
||||||
"IX_AuditLog_Target_Occurred",
|
"IX_AuditLog_Target_Occurred",
|
||||||
@@ -136,5 +139,9 @@ public class AuditLogEntityTypeConfigurationTests : IDisposable
|
|||||||
var targetIdx = entity.GetIndexes()
|
var targetIdx = entity.GetIndexes()
|
||||||
.Single(i => i.GetDatabaseName() == "IX_AuditLog_Target_Occurred");
|
.Single(i => i.GetDatabaseName() == "IX_AuditLog_Target_Occurred");
|
||||||
Assert.Equal("[Target] IS NOT NULL", targetIdx.GetFilter());
|
Assert.Equal("[Target] IS NOT NULL", targetIdx.GetFilter());
|
||||||
|
|
||||||
|
var executionIdx = entity.GetIndexes()
|
||||||
|
.Single(i => i.GetDatabaseName() == "IX_AuditLog_Execution");
|
||||||
|
Assert.Equal("[ExecutionId] IS NOT NULL", executionIdx.GetFilter());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-1
@@ -247,6 +247,34 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
|||||||
Assert.All(rows, r => Assert.Equal(siteId, r.SourceSiteId));
|
Assert.All(rows, r => Assert.Equal(siteId, r.SourceSiteId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task QueryAsync_FilterByExecutionId_ReturnsMatchingRows()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var siteId = NewSiteId();
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var repo = new AuditLogRepository(context);
|
||||||
|
|
||||||
|
var executionId = Guid.NewGuid();
|
||||||
|
var t0 = new DateTime(2026, 5, 3, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
// Two rows share the ExecutionId; one carries a different ExecutionId and
|
||||||
|
// one leaves it null — both must be excluded by the single-value filter.
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, executionId: executionId));
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), executionId: executionId));
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), executionId: Guid.NewGuid()));
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(3), executionId: null));
|
||||||
|
|
||||||
|
var rows = await repo.QueryAsync(
|
||||||
|
new AuditLogQueryFilter(
|
||||||
|
SourceSiteIds: new[] { siteId },
|
||||||
|
ExecutionId: executionId),
|
||||||
|
new AuditLogPaging(PageSize: 10));
|
||||||
|
|
||||||
|
Assert.Equal(2, rows.Count);
|
||||||
|
Assert.All(rows, r => Assert.Equal(executionId, r.ExecutionId));
|
||||||
|
}
|
||||||
|
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task QueryAsync_FilterByTimeRange()
|
public async Task QueryAsync_FilterByTimeRange()
|
||||||
{
|
{
|
||||||
@@ -725,7 +753,8 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
|||||||
AuditChannel channel = AuditChannel.ApiOutbound,
|
AuditChannel channel = AuditChannel.ApiOutbound,
|
||||||
AuditKind kind = AuditKind.ApiCall,
|
AuditKind kind = AuditKind.ApiCall,
|
||||||
AuditStatus status = AuditStatus.Delivered,
|
AuditStatus status = AuditStatus.Delivered,
|
||||||
string? errorMessage = null) =>
|
string? errorMessage = null,
|
||||||
|
Guid? executionId = null) =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
EventId = Guid.NewGuid(),
|
EventId = Guid.NewGuid(),
|
||||||
@@ -735,5 +764,6 @@ public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
|||||||
Status = status,
|
Status = status,
|
||||||
SourceSiteId = siteId,
|
SourceSiteId = siteId,
|
||||||
ErrorMessage = errorMessage,
|
ErrorMessage = errorMessage,
|
||||||
|
ExecutionId = executionId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user