Compare commits
5 Commits
a3d359fff7
...
13a33a6c78
| Author | SHA1 | Date | |
|---|---|---|---|
| 13a33a6c78 | |||
| adb5e75ec3 | |||
| 9e2416b34c | |||
| dd7626da63 | |||
| b1ea78a9fd |
+7
-26
@@ -42,8 +42,8 @@ module file and counted in **Total**.
|
|||||||
| Critical | 0 |
|
| Critical | 0 |
|
||||||
| High | 0 |
|
| High | 0 |
|
||||||
| Medium | 4 |
|
| Medium | 4 |
|
||||||
| Low | 23 |
|
| Low | 4 |
|
||||||
| **Total** | **27** |
|
| **Total** | **8** |
|
||||||
|
|
||||||
## Module Status
|
## Module Status
|
||||||
|
|
||||||
@@ -64,10 +64,10 @@ module file and counted in **Total**.
|
|||||||
| [ManagementService](ManagementService/findings.md) | 2026-05-16 | `9c60592` | 0/0/0/0 | 0 | 13 |
|
| [ManagementService](ManagementService/findings.md) | 2026-05-16 | `9c60592` | 0/0/0/0 | 0 | 13 |
|
||||||
| [NotificationService](NotificationService/findings.md) | 2026-05-16 | `9c60592` | 0/0/0/0 | 0 | 13 |
|
| [NotificationService](NotificationService/findings.md) | 2026-05-16 | `9c60592` | 0/0/0/0 | 0 | 13 |
|
||||||
| [Security](Security/findings.md) | 2026-05-16 | `9c60592` | 0/0/0/0 | 0 | 11 |
|
| [Security](Security/findings.md) | 2026-05-16 | `9c60592` | 0/0/0/0 | 0 | 11 |
|
||||||
| [SiteEventLogging](SiteEventLogging/findings.md) | 2026-05-16 | `9c60592` | 0/0/0/3 | 3 | 11 |
|
| [SiteEventLogging](SiteEventLogging/findings.md) | 2026-05-16 | `9c60592` | 0/0/0/0 | 0 | 11 |
|
||||||
| [SiteRuntime](SiteRuntime/findings.md) | 2026-05-16 | `9c60592` | 0/0/0/5 | 5 | 16 |
|
| [SiteRuntime](SiteRuntime/findings.md) | 2026-05-16 | `9c60592` | 0/0/0/0 | 0 | 16 |
|
||||||
| [StoreAndForward](StoreAndForward/findings.md) | 2026-05-16 | `9c60592` | 0/0/0/7 | 7 | 14 |
|
| [StoreAndForward](StoreAndForward/findings.md) | 2026-05-16 | `9c60592` | 0/0/0/0 | 0 | 14 |
|
||||||
| [TemplateEngine](TemplateEngine/findings.md) | 2026-05-16 | `9c60592` | 0/0/0/4 | 4 | 14 |
|
| [TemplateEngine](TemplateEngine/findings.md) | 2026-05-16 | `9c60592` | 0/0/0/0 | 0 | 14 |
|
||||||
|
|
||||||
## Pending Findings
|
## Pending Findings
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ _None open._
|
|||||||
| Host-002 | [Host](Host/findings.md) | Akka.Persistence required by REQ-HOST-6 is not configured and not used |
|
| Host-002 | [Host](Host/findings.md) | Akka.Persistence required by REQ-HOST-6 is not configured and not used |
|
||||||
| InboundAPI-007 | [InboundAPI](InboundAPI/findings.md) | `Database.Connection()` script API from the design doc is not implemented |
|
| InboundAPI-007 | [InboundAPI](InboundAPI/findings.md) | `Database.Connection()` script API from the design doc is not implemented |
|
||||||
|
|
||||||
### Low (23)
|
### Low (4)
|
||||||
|
|
||||||
| ID | Module | Title |
|
| ID | Module | Title |
|
||||||
|----|--------|-------|
|
|----|--------|-------|
|
||||||
@@ -101,22 +101,3 @@ _None open._
|
|||||||
| DeploymentManager-013 | [DeploymentManager](DeploymentManager/findings.md) | SMTP credentials serialized and broadcast to all sites |
|
| DeploymentManager-013 | [DeploymentManager](DeploymentManager/findings.md) | SMTP credentials serialized and broadcast to all sites |
|
||||||
| ExternalSystemGateway-011 | [ExternalSystemGateway](ExternalSystemGateway/findings.md) | Every call performs a full repository scan of all systems and methods |
|
| ExternalSystemGateway-011 | [ExternalSystemGateway](ExternalSystemGateway/findings.md) | Every call performs a full repository scan of all systems and methods |
|
||||||
| InboundAPI-012 | [InboundAPI](InboundAPI/findings.md) | `ParameterDefinition` POCO declared in the component project, not Commons |
|
| InboundAPI-012 | [InboundAPI](InboundAPI/findings.md) | `ParameterDefinition` POCO declared in the component project, not Commons |
|
||||||
| SiteEventLogging-006 | [SiteEventLogging](SiteEventLogging/findings.md) | Missing indexes for severity and keyword-search query paths |
|
|
||||||
| SiteEventLogging-009 | [SiteEventLogging](SiteEventLogging/findings.md) | XML doc on `LogEventAsync` claims asynchronous behaviour |
|
|
||||||
| SiteEventLogging-011 | [SiteEventLogging](SiteEventLogging/findings.md) | Stale "Phase 4+" placeholder in `ServiceCollectionExtensions` |
|
|
||||||
| SiteRuntime-012 | [SiteRuntime](SiteRuntime/findings.md) | `AttributeAccessor`/`ScopeAccessors` block the script on a synchronous Ask |
|
|
||||||
| SiteRuntime-013 | [SiteRuntime](SiteRuntime/findings.md) | `HandleUnsubscribeDebugView` does nothing despite documented behaviour |
|
|
||||||
| SiteRuntime-014 | [SiteRuntime](SiteRuntime/findings.md) | Trigger-expression evaluation blocks the coordinator actor thread |
|
|
||||||
| SiteRuntime-015 | [SiteRuntime](SiteRuntime/findings.md) | `LoggerFactory` created per Instance Actor and never disposed |
|
|
||||||
| SiteRuntime-016 | [SiteRuntime](SiteRuntime/findings.md) | Short-lived execution actors, replication actor, and repositories are untested |
|
|
||||||
| StoreAndForward-002 | [StoreAndForward](StoreAndForward/findings.md) | Messages enqueued with no registered handler are buffered but never deliverable |
|
|
||||||
| StoreAndForward-006 | [StoreAndForward](StoreAndForward/findings.md) | `GetParkedMessagesAsync` count and page run without a transaction |
|
|
||||||
| StoreAndForward-007 | [StoreAndForward](StoreAndForward/findings.md) | Async work in `ParkedMessageHandlerActor` uses `ContinueWith` without scheduler/affinity guarantees |
|
|
||||||
| StoreAndForward-008 | [StoreAndForward](StoreAndForward/findings.md) | A SQLite connection is opened and torn down on every storage call |
|
|
||||||
| StoreAndForward-009 | [StoreAndForward](StoreAndForward/findings.md) | `OnActivity` event invocation is not thread-safe against concurrent subscribe/unsubscribe |
|
|
||||||
| StoreAndForward-011 | [StoreAndForward](StoreAndForward/findings.md) | `StoreAndForwardMessageStatus.InFlight` is unused and the doc's "retrying" status is unmodelled |
|
|
||||||
| StoreAndForward-012 | [StoreAndForward](StoreAndForward/findings.md) | `StoreAndForwardMessage` is a persistence entity but lives in the component, not Commons |
|
|
||||||
| TemplateEngine-011 | [TemplateEngine](TemplateEngine/findings.md) | `SortedPropertiesConverterFactory` is dead code with a misleading comment |
|
|
||||||
| TemplateEngine-012 | [TemplateEngine](TemplateEngine/findings.md) | `DataType` enum naming diverges from the design doc |
|
|
||||||
| TemplateEngine-013 | [TemplateEngine](TemplateEngine/findings.md) | `ToDictionary(t => t.Id)` throws on duplicate IDs; cycle detectors overload Id 0 as a sentinel |
|
|
||||||
| TemplateEngine-014 | [TemplateEngine](TemplateEngine/findings.md) | Template-deletion constraint logic is duplicated and divergent |
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
| Last reviewed | 2026-05-16 |
|
| Last reviewed | 2026-05-16 |
|
||||||
| Reviewer | claude-agent |
|
| Reviewer | claude-agent |
|
||||||
| Commit reviewed | `9c60592` |
|
| Commit reviewed | `9c60592` |
|
||||||
| Open findings | 3 |
|
| Open findings | 0 |
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
@@ -284,7 +284,7 @@ caller returns in <500 ms while the database is held busy) plus
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Performance & resource management |
|
| Category | Performance & resource management |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.SiteEventLogging/SiteEventLogger.cs:50-52`, `src/ScadaLink.SiteEventLogging/EventLogQueryService.cs:65-81` |
|
| Location | `src/ScadaLink.SiteEventLogging/SiteEventLogger.cs:50-52`, `src/ScadaLink.SiteEventLogging/EventLogQueryService.cs:65-81` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -306,7 +306,14 @@ cost.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit `pending`): `InitializeSchema` now creates
|
||||||
|
`idx_events_severity` on `site_events(severity)`, so severity-filtered queries use an
|
||||||
|
index instead of full-scanning. The leading-wildcard `LIKE` keyword search cannot use
|
||||||
|
a B-tree index by construction; rather than adding an FTS5 table, that scan is
|
||||||
|
accepted and now documented with an inline comment next to the index DDL. Regression
|
||||||
|
tests `Schema_HasIndexOnSeverity` and `SeverityFilteredQuery_UsesIndex_NotFullScan`
|
||||||
|
(the latter asserts the SQLite query plan uses `idx_events_severity` and does not
|
||||||
|
`SCAN site_events`).
|
||||||
|
|
||||||
### SiteEventLogging-007 — `ISiteEventLogger` consumers downcast to the concrete type
|
### SiteEventLogging-007 — `ISiteEventLogger` consumers downcast to the concrete type
|
||||||
|
|
||||||
@@ -397,7 +404,7 @@ Regression test `LogEventAsync_FaultsTask_AndCountsFailure_OnWriteError`.
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Documentation & comments |
|
| Category | Documentation & comments |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.SiteEventLogging/ISiteEventLogger.cs:8-10`, `src/ScadaLink.SiteEventLogging/SiteEventLogger.cs:57` |
|
| Location | `src/ScadaLink.SiteEventLogging/ISiteEventLogger.cs:8-10`, `src/ScadaLink.SiteEventLogging/SiteEventLogger.cs:57` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -409,6 +416,16 @@ reasonably assume the write is offloaded and the caller's thread is not blocked,
|
|||||||
which is false. The `details` parameter doc says "Optional JSON details" but nothing
|
which is false. The `details` parameter doc says "Optional JSON details" but nothing
|
||||||
validates or requires JSON, so callers may pass arbitrary text.
|
validates or requires JSON, so callers may pass arbitrary text.
|
||||||
|
|
||||||
|
**Re-triage note (2026-05-16)**
|
||||||
|
|
||||||
|
The finding's central premise is now stale. SiteEventLogging-005 was resolved by
|
||||||
|
introducing a dedicated background writer: `LogEventAsync` enqueues onto an unbounded
|
||||||
|
`Channel<T>` and returns without blocking, so the method is now *genuinely*
|
||||||
|
asynchronous and the name `LogEventAsync` is accurate. No rename is warranted. The
|
||||||
|
only residual documentation defect was the imprecise XML doc (the one-line summary
|
||||||
|
did not describe the enqueue/await semantics) and the `details` doc claiming "JSON"
|
||||||
|
when nothing enforces it. Scope corrected to those doc fixes only.
|
||||||
|
|
||||||
**Recommendation**
|
**Recommendation**
|
||||||
|
|
||||||
Align the name, signature, and documentation with the actual behaviour — either make
|
Align the name, signature, and documentation with the actual behaviour — either make
|
||||||
@@ -417,7 +434,16 @@ Clarify that `details` is free-form text unless JSON is actually enforced.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit `pending`): the `ISiteEventLogger.LogEventAsync` XML doc
|
||||||
|
now describes the actual behaviour — the call enqueues onto a background writer and
|
||||||
|
returns without blocking, and the returned `Task` completes on durable persistence
|
||||||
|
(or faults on write failure). The `details` parameter doc was corrected to "Optional
|
||||||
|
free-form detail text ... JSON is conventional but not validated or enforced". No
|
||||||
|
rename was needed: post-SiteEventLogging-005 the method is genuinely asynchronous, so
|
||||||
|
`LogEventAsync` is accurate (see Re-triage note). Documentation-only change; no
|
||||||
|
regression test added (behaviour is already covered by the SiteEventLogging-005
|
||||||
|
tests `LogEventAsync_DoesNotBlockCaller_WhenWriteIsSlow` and
|
||||||
|
`LogEventAsync_TaskCompletes_AfterEventIsPersisted`).
|
||||||
|
|
||||||
### SiteEventLogging-010 — Test coverage gaps: actor bridge, purge/write concurrency, vacuum effectiveness, query error path
|
### SiteEventLogging-010 — Test coverage gaps: actor bridge, purge/write concurrency, vacuum effectiveness, query error path
|
||||||
|
|
||||||
@@ -473,7 +499,7 @@ SiteEventLogging-001/-002/-003 were resolved
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Documentation & comments |
|
| Category | Documentation & comments |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.SiteEventLogging/ServiceCollectionExtensions.cs:18-22` |
|
| Location | `src/ScadaLink.SiteEventLogging/ServiceCollectionExtensions.cs:18-22` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -493,4 +519,13 @@ the misleading comment.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit `pending`): confirmed dead code — a repo-wide search
|
||||||
|
found zero callers of `AddSiteEventLoggingActors`, and `EventLogHandlerActor` is in
|
||||||
|
fact wired up directly in `ScadaLink.Host/Actors/AkkaHostedService.cs` as a cluster
|
||||||
|
singleton (it must be created inside the `ActorSystem` with a resolved
|
||||||
|
`IEventLogQueryService`, which a `IServiceCollection` extension cannot do). The empty
|
||||||
|
placeholder method and its stale "Phase 4+" comment were deleted, and a short
|
||||||
|
explanatory note added to `AddSiteEventLogging` pointing readers to where the actor
|
||||||
|
is actually registered. Documentation/dead-code change only; no regression test was
|
||||||
|
added — the change is a method removal verified by the compiler (no callers) and the
|
||||||
|
full module suite still passing.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
| Last reviewed | 2026-05-16 |
|
| Last reviewed | 2026-05-16 |
|
||||||
| Reviewer | claude-agent |
|
| Reviewer | claude-agent |
|
||||||
| Commit reviewed | `9c60592` |
|
| Commit reviewed | `9c60592` |
|
||||||
| Open findings | 5 |
|
| Open findings | 0 |
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
@@ -521,7 +521,7 @@ literal/identifier non-detection, allowed-exception resolution); all 39 existing
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Concurrency & thread safety |
|
| Category | Concurrency & thread safety |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.SiteRuntime/Scripts/ScopeAccessors.cs:28` |
|
| Location | `src/ScadaLink.SiteRuntime/Scripts/ScopeAccessors.cs:28` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -543,7 +543,18 @@ internal-only and exposing only the async API.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (`pending commit`): re-triaged against the current source — the
|
||||||
|
finding's own recommendation states the blocking is *acceptable* once SiteRuntime-009's
|
||||||
|
dedicated script dispatcher exists, and SiteRuntime-009 is now Resolved
|
||||||
|
(`ScriptExecutionActor`/`AlarmExecutionActor` run script bodies on the dedicated
|
||||||
|
`ScriptExecutionScheduler` threads, confirmed in `ScriptExecutionActor.cs:74`). A
|
||||||
|
blocked accessor therefore can no longer starve the shared `ThreadPool` or Akka
|
||||||
|
dispatchers — only a dedicated script thread. The remaining defect was the misleading
|
||||||
|
class XML comment, which only said "Reads block on the actor Ask" with no thread-model
|
||||||
|
context. The `AttributeAccessor` XML doc now documents the dispatcher containment
|
||||||
|
(SiteRuntime-009) explicitly and still steers authors to the async `GetAsync`/`SetAsync`
|
||||||
|
variants. No behavioural change — this is a documentation finding; existing
|
||||||
|
`ScopeAccessorTests` continue to pass.
|
||||||
|
|
||||||
### SiteRuntime-013 — `HandleUnsubscribeDebugView` does nothing despite documented behaviour
|
### SiteRuntime-013 — `HandleUnsubscribeDebugView` does nothing despite documented behaviour
|
||||||
|
|
||||||
@@ -551,7 +562,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Documentation & comments |
|
| Category | Documentation & comments |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs:414` |
|
| Location | `src/ScadaLink.SiteRuntime/Actors/InstanceActor.cs:414` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -573,7 +584,18 @@ comment to state explicitly that subscription teardown is handled by
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (`pending commit`): root cause confirmed — the Instance Actor
|
||||||
|
holds no per-subscriber state, so `HandleUnsubscribeDebugView` genuinely has nothing to
|
||||||
|
remove; the real debug-stream subscription lifecycle lives in `SiteStreamManager`
|
||||||
|
(Subscribe/Unsubscribe/RemoveSubscriber). The recommendation's "correct the XML comment"
|
||||||
|
option was taken (removing the handler would still leave `UnsubscribeDebugViewRequest`
|
||||||
|
routed here from `DeploymentManagerActor.RouteDebugViewUnsubscribe`, and the no-op
|
||||||
|
acknowledgement is harmless). The XML doc on `HandleUnsubscribeDebugView` now states
|
||||||
|
explicitly that it is a deliberate no-op acknowledgement and that teardown is handled by
|
||||||
|
`SiteStreamManager`; the log message likewise notes "(no-op; subscription teardown
|
||||||
|
handled by SiteStreamManager)". This is a documentation-only finding with no observable
|
||||||
|
behaviour to regression-test, so no new test was added; the existing
|
||||||
|
`InstanceActor`/debug-view tests continue to pass.
|
||||||
|
|
||||||
### SiteRuntime-014 — Trigger-expression evaluation blocks the coordinator actor thread
|
### SiteRuntime-014 — Trigger-expression evaluation blocks the coordinator actor thread
|
||||||
|
|
||||||
@@ -581,7 +603,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Akka.NET conventions |
|
| Category | Akka.NET conventions |
|
||||||
| Status | Open |
|
| Status | Deferred |
|
||||||
| Location | `src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs:219`, `src/ScadaLink.SiteRuntime/Actors/AlarmActor.cs:389` |
|
| Location | `src/ScadaLink.SiteRuntime/Actors/ScriptActor.cs:219`, `src/ScadaLink.SiteRuntime/Actors/AlarmActor.cs:389` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -605,7 +627,26 @@ without the Roslyn scripting `RunAsync` machinery.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Deferred 2026-05-16 (`pending commit`): root cause confirmed — `EvaluateExpressionTrigger`
|
||||||
|
(ScriptActor) and `EvaluateExpression` (AlarmActor) call
|
||||||
|
`_compiledTriggerExpression.RunAsync(...).GetAwaiter().GetResult()` directly inside the
|
||||||
|
`AttributeValueChanged` handler, on the coordinator actor's default (thread-pool-backed)
|
||||||
|
dispatcher, blocking the mailbox for up to the 2 s timeout. Re-triaged from Open to
|
||||||
|
**Deferred** rather than fixed: neither recommended fix stays cleanly in-module without
|
||||||
|
a design decision. (a) **Off-thread eval + pipe-back** changes the actor's concurrency
|
||||||
|
model — the evaluation carries edge-tracking state (`_lastExpressionResult`) and a
|
||||||
|
mutable `_attributeSnapshot`; multiple `AttributeValueChanged` messages can arrive while
|
||||||
|
an evaluation is in flight, so a correct fix must decide overlapping-evaluation
|
||||||
|
semantics (coalesce / serialize / drop) and the snapshot-coherence contract — a behaviour
|
||||||
|
change to the trigger model. (b) **Pre-compile to a plain delegate** would require
|
||||||
|
changing the compilation contract: the trigger expression is produced as a Roslyn
|
||||||
|
`Script<object?>` by `ScriptCompilationService.CompileTriggerExpression`, which is also
|
||||||
|
the security boundary (SiteRuntime-011 trust validation); swapping the artifact type is
|
||||||
|
a cross-component change touching the Template Engine / Deployment Manager compile
|
||||||
|
pipeline. Given Low severity, a bounded 2 s worst case, and the inline note that trigger
|
||||||
|
expressions are trusted, compile-checked, and expected to be cheap, this is left
|
||||||
|
Deferred pending a design decision on trigger-evaluation concurrency rather than forcing
|
||||||
|
an out-of-scope or messaging-contract-changing fix.
|
||||||
|
|
||||||
### SiteRuntime-015 — `LoggerFactory` created per Instance Actor and never disposed
|
### SiteRuntime-015 — `LoggerFactory` created per Instance Actor and never disposed
|
||||||
|
|
||||||
@@ -613,7 +654,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Performance & resource management |
|
| Category | Performance & resource management |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs:746` |
|
| Location | `src/ScadaLink.SiteRuntime/Actors/DeploymentManagerActor.cs:746` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -634,7 +675,18 @@ up per child. Do not create a fresh `LoggerFactory` in a hot creation path.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (`pending commit`): root cause confirmed — `CreateInstanceActor`
|
||||||
|
did `new LoggerFactory()` per Instance Actor, never disposed, and detached from the
|
||||||
|
host's logging providers. `DeploymentManagerActor` now holds a single `_loggerFactory`
|
||||||
|
field, resolved once in the constructor from (in order) a new optional `ILoggerFactory`
|
||||||
|
constructor parameter, the injected `IServiceProvider`, or `NullLoggerFactory.Instance`
|
||||||
|
as a last resort — never a per-instance allocation. `CreateInstanceActor` mints the
|
||||||
|
`InstanceActor` logger from this shared factory, so loggers are routed through the
|
||||||
|
application's configured providers and no factory leaks. Regression test:
|
||||||
|
`DeploymentManagerLoggerFactoryTests.CreateInstanceActor_ReusesInjectedLoggerFactory_ForEveryInstance`
|
||||||
|
injects a counting `ILoggerFactory` and asserts it is used once per created Instance
|
||||||
|
Actor — confirmed to fail (0 calls) against the pre-fix `new LoggerFactory()` code and
|
||||||
|
pass after the fix.
|
||||||
|
|
||||||
### SiteRuntime-016 — Short-lived execution actors, replication actor, and repositories are untested
|
### SiteRuntime-016 — Short-lived execution actors, replication actor, and repositories are untested
|
||||||
|
|
||||||
@@ -642,7 +694,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Testing coverage |
|
| Category | Testing coverage |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `tests/ScadaLink.SiteRuntime.Tests/` |
|
| Location | `tests/ScadaLink.SiteRuntime.Tests/` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -666,4 +718,18 @@ synthetic-ID lookup, missing-row behaviour).
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (`pending commit`): re-triaged against the current test sources —
|
||||||
|
`SiteExternalSystemRepository` and `SiteNotificationRepository` are already covered by
|
||||||
|
`Repositories/SiteRepositoryTests.cs` (added when SiteRuntime-006/007 were resolved:
|
||||||
|
round-trip read and synthetic-ID-stable-across-restart). The execution-actor gap is now
|
||||||
|
closed by a new `Actors/ExecutionActorTests.cs` — six tests covering
|
||||||
|
`ScriptExecutionActor` (success → `ScriptCallResult` reply + PoisonPill self-stop;
|
||||||
|
script-throws → failure reply + stop; cooperative timeout → failure reply + stop;
|
||||||
|
no-`replyTo` fire-and-forget still self-stops) and `AlarmExecutionActor` (success →
|
||||||
|
self-stop; on-trigger throws → still self-stops). `SiteReplicationActor` is *not* covered
|
||||||
|
here: it depends on `Cluster.Get(Context.System)` and so requires a clustered
|
||||||
|
`ActorSystem` HOCON harness that does not yet exist in this test project — adding that
|
||||||
|
harness is a larger test-infrastructure task tracked separately and out of scope for a
|
||||||
|
Low-severity coverage finding; the highest-value untested paths the finding called out
|
||||||
|
(script timeout/failure/reply/self-stop) are now covered. Full module suite: 192 tests
|
||||||
|
green.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
| Last reviewed | 2026-05-16 |
|
| Last reviewed | 2026-05-16 |
|
||||||
| Reviewer | claude-agent |
|
| Reviewer | claude-agent |
|
||||||
| Commit reviewed | `9c60592` |
|
| Commit reviewed | `9c60592` |
|
||||||
| Open findings | 7 |
|
| Open findings | 0 (3 Deferred: 002, 011, 012 — see notes) |
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ commit whose message references `StoreAndForward-001`.
|
|||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Original severity | High (re-triaged down to Low on 2026-05-16 — see Re-triage note) |
|
| Original severity | High (re-triaged down to Low on 2026-05-16 — see Re-triage note) |
|
||||||
| Category | Error handling & resilience |
|
| Category | Error handling & resilience |
|
||||||
| Status | Open |
|
| Status | Deferred |
|
||||||
| Location | `src/ScadaLink.StoreAndForward/StoreAndForwardService.cs:162`, `:201` |
|
| Location | `src/ScadaLink.StoreAndForward/StoreAndForwardService.cs:162`, `:201` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -152,9 +152,17 @@ should be made deliberately rather than forced here.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Open — re-triaged to Low. Premise (no handler registration anywhere) is stale: Host
|
_Deferred 2026-05-16 (re-triaged High → Low). Verified again against the source in this
|
||||||
now wires all three handlers. Residual gap is minor and the prescribed fix is a
|
pass: the finding's premise (no `RegisterDeliveryHandler` caller anywhere) is stale —
|
||||||
cross-module contract change needing a design decision._
|
`ScadaLink.Host` wires all three handlers at site startup — so the High-severity
|
||||||
|
"engine cannot deliver anything" outcome no longer occurs. The residual gap (a message
|
||||||
|
enqueued for a category that genuinely has no handler is buffered then skipped forever)
|
||||||
|
is real but minor. The prescribed fix — making `EnqueueAsync` reject when no handler is
|
||||||
|
registered — is a behavioural contract change that depends on whether late handler
|
||||||
|
registration is supported and requires updating tests in NotificationService and
|
||||||
|
ExternalSystemGateway (modules outside this review's edit scope). That is a deliberate
|
||||||
|
cross-module design decision, not a localised in-module bug fix, so it is **Deferred**
|
||||||
|
pending that decision rather than forced here._
|
||||||
|
|
||||||
### StoreAndForward-003 — Off-by-one in retry accounting: immediate failure pre-counts as retry 1
|
### StoreAndForward-003 — Off-by-one in retry accounting: immediate failure pre-counts as retry 1
|
||||||
|
|
||||||
@@ -319,7 +327,7 @@ other writer's `RetryCount`).
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Performance & resource management |
|
| Category | Performance & resource management |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.StoreAndForward/StoreAndForwardStorage.cs:166`, `:175` |
|
| Location | `src/ScadaLink.StoreAndForward/StoreAndForwardStorage.cs:166`, `:175` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -339,7 +347,22 @@ removes the inconsistency.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit pending). Confirmed the root cause against the source —
|
||||||
|
`GetParkedMessagesAsync` issued `COUNT(*)` then a paged `SELECT` as two separate
|
||||||
|
commands on the same connection with no surrounding transaction, so a write committed
|
||||||
|
between them yields a `TotalCount` inconsistent with the page. Applied the
|
||||||
|
recommendation's preferred option: both reads now run inside a single
|
||||||
|
`SqliteTransaction` (`BeginTransactionAsync`), and `CommitAsync` is called after the
|
||||||
|
page is read; SQLite's deferred read transaction freezes a consistent snapshot on the
|
||||||
|
first read so the count and page agree. Regression test
|
||||||
|
`GetParkedMessagesAsync_TransactionedReads_CountMatchesFullResultSet` is a functional
|
||||||
|
guard that the transaction wiring did not break pagination (reported `TotalCount`
|
||||||
|
agrees with the rows assembled across all pages). Note: a true red-then-green TDD test
|
||||||
|
of the *race itself* is not achievable deterministically — reproducing it requires a
|
||||||
|
concurrent writer to commit in the sub-millisecond window between the two adjacent
|
||||||
|
`SELECT`s; a concurrent stress harness passed even against the pre-fix code, so it
|
||||||
|
would not be a real regression test. The fix is nonetheless correct and matches the
|
||||||
|
finding's recommendation.
|
||||||
|
|
||||||
### StoreAndForward-007 — Async work in `ParkedMessageHandlerActor` uses `ContinueWith` without scheduler/affinity guarantees
|
### StoreAndForward-007 — Async work in `ParkedMessageHandlerActor` uses `ContinueWith` without scheduler/affinity guarantees
|
||||||
|
|
||||||
@@ -347,7 +370,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Akka.NET conventions |
|
| Category | Akka.NET conventions |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.StoreAndForward/ParkedMessageHandlerActor.cs:34`, `:68`, `:87` |
|
| Location | `src/ScadaLink.StoreAndForward/ParkedMessageHandlerActor.cs:34`, `:68`, `:87` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -370,7 +393,21 @@ off the actor thread safely, and makes the success/failure branches explicit.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit pending). Confirmed the root cause: all three handlers
|
||||||
|
(`HandleQuery`, `HandleRetry`, `HandleDiscard`) used `ContinueWith(...).PipeTo(sender)`
|
||||||
|
with an `IsCompletedSuccessfully` check standing in for explicit success/failure
|
||||||
|
branches. Applied the recommendation exactly — each now uses
|
||||||
|
`PipeTo(sender, success: ..., failure: ...)`, the documented Akka pattern: the success
|
||||||
|
projection builds the normal response, the failure projection builds the error
|
||||||
|
response, and a faulted antecedent unambiguously routes to `failure` rather than
|
||||||
|
relying on an `IsCompletedSuccessfully` convention. `Sender` is still captured into a
|
||||||
|
local before the await, and the projections touch only locals. This is a
|
||||||
|
behaviour-preserving refactor; the existing `ParkedMessageHandlerActorTests` (8 tests
|
||||||
|
covering Query/Retry/Discard request-to-response mapping, correlation-ID propagation
|
||||||
|
and the unknown-message responses) act as the regression suite and all pass. No new
|
||||||
|
test was added because the observable behaviour is unchanged and the `failure`
|
||||||
|
projection cannot be exercised without a service that throws — `StoreAndForwardService`
|
||||||
|
is a concrete non-virtual type with no failure-injection seam.
|
||||||
|
|
||||||
### StoreAndForward-008 — A SQLite connection is opened and torn down on every storage call
|
### StoreAndForward-008 — A SQLite connection is opened and torn down on every storage call
|
||||||
|
|
||||||
@@ -378,7 +415,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Performance & resource management |
|
| Category | Performance & resource management |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.StoreAndForward/StoreAndForwardStorage.cs:28`, `:61`, `:93`, `:117`, `:144`, `:162`, `:199`, `:221`, `:237`, `:267`, `:285`, `:305`, `:319` |
|
| Location | `src/ScadaLink.StoreAndForward/StoreAndForwardStorage.cs:28`, `:61`, `:93`, `:117`, `:144`, `:162`, `:199`, `:221`, `:237`, `:267`, `:285`, `:305`, `:319` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -398,7 +435,18 @@ the design relies on the Sqlite connection pool for acceptable performance.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit pending). Confirmed the finding's analysis is accurate but
|
||||||
|
correctly classified as Low/not-a-correctness-bug: Microsoft.Data.Sqlite pools
|
||||||
|
connections, so the per-call `OpenAsync` reuses a pooled handle. Applied the "at
|
||||||
|
minimum" remedy from the recommendation — the `StoreAndForwardStorage` class XML
|
||||||
|
documentation now explicitly records that the connection-per-call style is a deliberate
|
||||||
|
trade-off, that the retry sweep's acceptable performance relies on the
|
||||||
|
Microsoft.Data.Sqlite connection pool, and that the remedy if profiling ever shows the
|
||||||
|
pooled open to be a hot-path bottleneck is a batched sweep API opening one connection
|
||||||
|
and transaction per sweep. The larger batched-API refactor was not undertaken because
|
||||||
|
it is not warranted at Low severity and the documented design intent removes the
|
||||||
|
"silent reliance on the pool" concern. Documentation-only change — no behavioural code
|
||||||
|
touched, so no regression test (the connection-pool reliance is not test-observable).
|
||||||
|
|
||||||
### StoreAndForward-009 — `OnActivity` event invocation is not thread-safe against concurrent subscribe/unsubscribe
|
### StoreAndForward-009 — `OnActivity` event invocation is not thread-safe against concurrent subscribe/unsubscribe
|
||||||
|
|
||||||
@@ -406,7 +454,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Concurrency & thread safety |
|
| Category | Concurrency & thread safety |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.StoreAndForward/StoreAndForwardService.cs:46`, `:309` |
|
| Location | `src/ScadaLink.StoreAndForward/StoreAndForwardService.cs:46`, `:309` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -430,7 +478,21 @@ notifications asynchronously.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit pending). Confirmed — and found the impact is worse than
|
||||||
|
the finding states. `RaiseActivity` previously did `OnActivity?.Invoke(...)`; a
|
||||||
|
throwing subscriber's exception escaped it. On the `EnqueueAsync` immediate-success
|
||||||
|
path the `RaiseActivity("Delivered", ...)` call sits *inside* the delivery `try`, so a
|
||||||
|
throwing subscriber was caught by the transient-failure handler — a successfully
|
||||||
|
delivered message was then buffered, and because the catch block's own
|
||||||
|
`RaiseActivity("Queued", ...)` also threw, the exception escaped `EnqueueAsync`
|
||||||
|
entirely. `RaiseActivity` now snapshots `OnActivity`, iterates its invocation list, and
|
||||||
|
wraps each subscriber call in `try/catch` (logging and ignoring a fault) — activity
|
||||||
|
logging is best-effort and a slow/throwing subscriber can neither abort the caller nor
|
||||||
|
be misclassified as a delivery failure. Regression tests:
|
||||||
|
`EnqueueAsync_ImmediateDeliverySuccess_FaultingActivitySubscriber_StillReportsDelivered`
|
||||||
|
(failed pre-fix — the subscriber exception escaped and the call threw; passes post-fix
|
||||||
|
with `WasBuffered == false` and an empty buffer) and
|
||||||
|
`RetryMessageAsync_FaultingActivitySubscriber_DoesNotIncrementRetryCount`.
|
||||||
|
|
||||||
### StoreAndForward-010 — Retry of a parked message does not reset `LastAttemptAt`, so its retry timing is unspecified
|
### StoreAndForward-010 — Retry of a parked message does not reset `LastAttemptAt`, so its retry timing is unspecified
|
||||||
|
|
||||||
@@ -479,7 +541,7 @@ cleared, message excluded from the retry-due set) and passes post-fix.
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Design-document adherence |
|
| Category | Design-document adherence |
|
||||||
| Status | Open |
|
| Status | Deferred |
|
||||||
| Location | `src/ScadaLink.Commons/Types/Enums/StoreAndForwardMessageStatus.cs:9`; `src/ScadaLink.StoreAndForward/StoreAndForwardService.cs:219`, `:235` |
|
| Location | `src/ScadaLink.Commons/Types/Enums/StoreAndForwardMessageStatus.cs:9`; `src/ScadaLink.StoreAndForward/StoreAndForwardService.cs:219`, `:235` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -500,7 +562,18 @@ sweep is actively delivering (which would also help with finding 005).
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
_Deferred 2026-05-16. Confirmed against the source: `StoreAndForwardMessageStatus`
|
||||||
|
defines `Pending, InFlight, Parked, Delivered`; a codebase-wide search shows the
|
||||||
|
StoreAndForward module only ever assigns `Pending` and `Parked`, and `InFlight` /
|
||||||
|
`Delivered` are never assigned anywhere (delivered messages are deleted, not marked).
|
||||||
|
The design doc's `retrying` state is unmodelled. Both options the recommendation offers
|
||||||
|
— (a) drop the unused `InFlight`/`Delivered` members, or (b) add a `Retrying` member —
|
||||||
|
require editing `StoreAndForwardMessageStatus.cs`, which lives in `src/ScadaLink.Commons`
|
||||||
|
(outside this review's edit scope: only `src/ScadaLink.StoreAndForward/**` may be
|
||||||
|
changed). The enum is also referenced by IntegrationTests and HealthMonitoring tests, so
|
||||||
|
removing members is a cross-module change. The defect is real but cannot be resolved
|
||||||
|
in-module; **Deferred** to a change that owns the Commons enum and the design doc
|
||||||
|
together._
|
||||||
|
|
||||||
### StoreAndForward-012 — `StoreAndForwardMessage` is a persistence entity but lives in the component, not Commons
|
### StoreAndForward-012 — `StoreAndForwardMessage` is a persistence entity but lives in the component, not Commons
|
||||||
|
|
||||||
@@ -508,7 +581,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Code organization & conventions |
|
| Category | Code organization & conventions |
|
||||||
| Status | Open |
|
| Status | Deferred |
|
||||||
| Location | `src/ScadaLink.StoreAndForward/StoreAndForwardMessage.cs:9` |
|
| Location | `src/ScadaLink.StoreAndForward/StoreAndForwardMessage.cs:9` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -532,7 +605,19 @@ local persistence model. Document the decision.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
_Deferred 2026-05-16. Confirmed: `StoreAndForwardMessage` is a persistence-ignorant POCO
|
||||||
|
mapping to `sf_messages` and is also carried across Akka remoting inside
|
||||||
|
`ReplicationOperation`, so it doubles as a de-facto wire contract while living in the
|
||||||
|
component assembly rather than the Commons `Entities`/`Messages` hierarchy. The
|
||||||
|
recommendation's primary remedy — moving `StoreAndForwardMessage` (and
|
||||||
|
`ReplicationOperation`) into Commons — crosses module boundaries (it would add a type to
|
||||||
|
`src/ScadaLink.Commons`, outside this review's edit scope of
|
||||||
|
`src/ScadaLink.StoreAndForward/**`, and change every referencing module). The alternative
|
||||||
|
"separate replication DTO" still leaves the persistence entity in the component, so it
|
||||||
|
does not actually resolve the finding's core concern (entity placement / contract-
|
||||||
|
evolution governance). Resolving this is a deliberate code-organisation decision that
|
||||||
|
must own the Commons hierarchy; **Deferred** rather than forced in-module. Flagged for a
|
||||||
|
cross-module follow-up._
|
||||||
|
|
||||||
### StoreAndForward-013 — Critical paths lack test coverage: retry-due timing, replication-from-active, and the actor bridge
|
### StoreAndForward-013 — Critical paths lack test coverage: retry-due timing, replication-from-active, and the actor bridge
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
| Last reviewed | 2026-05-16 |
|
| Last reviewed | 2026-05-16 |
|
||||||
| Reviewer | claude-agent |
|
| Reviewer | claude-agent |
|
||||||
| Commit reviewed | `9c60592` |
|
| Commit reviewed | `9c60592` |
|
||||||
| Open findings | 4 |
|
| Open findings | 0 |
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
@@ -474,7 +474,7 @@ behaviour change; no regression test (documentation-only).
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Documentation & comments |
|
| Category | Documentation & comments |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.TemplateEngine/Flattening/RevisionHashService.cs:136` |
|
| Location | `src/ScadaLink.TemplateEngine/Flattening/RevisionHashService.cs:136` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -497,7 +497,18 @@ ordering of the `Hashable*` records (ideally enforced by a test).
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit `pending commit`): deleted the dead
|
||||||
|
`SortedPropertiesConverterFactory` (and removed it from `CanonicalJsonOptions`),
|
||||||
|
and replaced the misleading "sorted keys / consistent ordering" comments with an
|
||||||
|
explicit DETERMINISM CONTRACT note on the `RevisionHashService` class summary —
|
||||||
|
`System.Text.Json` serializes properties in CLR declaration order and does not
|
||||||
|
sort, so stable hashes rely on the private `Hashable*` records declaring their
|
||||||
|
properties alphabetically (collections are explicitly sorted by `CanonicalName`).
|
||||||
|
That manual ordering is now guarded by a regression test:
|
||||||
|
`RevisionHashServiceTests.HashableRecords_PropertiesDeclaredAlphabetically`
|
||||||
|
(reflects over the nested `Hashable*` types and asserts ordinal-alphabetical
|
||||||
|
property declaration order), so adding a property out of order now fails the build's
|
||||||
|
test gate instead of silently changing every revision hash.
|
||||||
|
|
||||||
### TemplateEngine-012 — `DataType` enum naming diverges from the design doc
|
### TemplateEngine-012 — `DataType` enum naming diverges from the design doc
|
||||||
|
|
||||||
@@ -505,7 +516,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Design-document adherence |
|
| Category | Design-document adherence |
|
||||||
| Status | Open |
|
| Status | Deferred |
|
||||||
| Location | `src/ScadaLink.TemplateEngine/Validation/SemanticValidator.cs:18` |
|
| Location | `src/ScadaLink.TemplateEngine/Validation/SemanticValidator.cs:18` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -523,9 +534,30 @@ are numeric.
|
|||||||
Update `docs/requirements/Component-TemplateEngine.md` to list the actual enum members,
|
Update `docs/requirements/Component-TemplateEngine.md` to list the actual enum members,
|
||||||
or rename the enum to match the doc if "Integer" is the intended canonical name.
|
or rename the enum to match the doc if "Integer" is the intended canonical name.
|
||||||
|
|
||||||
|
**Re-triage**
|
||||||
|
|
||||||
|
Verified against the source: the `DataType` enum is declared in
|
||||||
|
`src/ScadaLink.Commons/Types/Enums/DataType.cs` (`Boolean, Int32, Float, Double,
|
||||||
|
String, DateTime, Binary`) — **not** in the TemplateEngine module — and is consumed
|
||||||
|
across modules (`TemplateAttribute` entity, management command contracts). The only
|
||||||
|
in-module file the finding cites, `SemanticValidator.cs:18`, is confirmed **correct**:
|
||||||
|
`NumericDataTypes` already hard-codes the real enum names. Both remediation options
|
||||||
|
in the recommendation therefore land **outside** this module's resolution boundary
|
||||||
|
(`src/ScadaLink.TemplateEngine/**`): renaming the enum touches `ScadaLink.Commons`
|
||||||
|
(and every consumer of `DataType`), and the alternative — updating the design doc —
|
||||||
|
touches `docs/requirements/Component-TemplateEngine.md`. There is no in-module code
|
||||||
|
defect to fix. Re-triaged from Open to Deferred: the fix is a one-line design-doc
|
||||||
|
correction (list the actual seven enum members instead of "Boolean, Integer, Float,
|
||||||
|
String") that must be made by an agent owning the docs / Commons scope.
|
||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Deferred 2026-05-16 (no commit): no in-module fix possible — see Re-triage. The
|
||||||
|
TemplateEngine code is correct as-is. FLAGGED for the docs owner: correct the
|
||||||
|
Attribute data-type list in `docs/requirements/Component-TemplateEngine.md` to match
|
||||||
|
`ScadaLink.Commons` `DataType` (`Boolean, Int32, Float, Double, String, DateTime,
|
||||||
|
Binary`). Renaming the enum is not recommended (cross-module churn for no behavioural
|
||||||
|
gain); the doc is the authoritative thing to fix.
|
||||||
|
|
||||||
### TemplateEngine-013 — `ToDictionary(t => t.Id)` throws on duplicate IDs; cycle detectors overload Id 0 as a sentinel
|
### TemplateEngine-013 — `ToDictionary(t => t.Id)` throws on duplicate IDs; cycle detectors overload Id 0 as a sentinel
|
||||||
|
|
||||||
@@ -533,7 +565,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Correctness & logic bugs |
|
| Category | Correctness & logic bugs |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.TemplateEngine/CycleDetector.cs:30`, `src/ScadaLink.TemplateEngine/CycleDetector.cs:38` |
|
| Location | `src/ScadaLink.TemplateEngine/CycleDetector.cs:30`, `src/ScadaLink.TemplateEngine/CycleDetector.cs:38` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -556,7 +588,23 @@ special.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit `pending commit`): added `CycleDetector.BuildLookup`,
|
||||||
|
a duplicate-tolerant Id-keyed lookup (`Dictionary` + `TryAdd`, first occurrence wins)
|
||||||
|
that replaces every `allTemplates.ToDictionary(t => t.Id)` in the static helpers —
|
||||||
|
`CycleDetector` (all three methods), `TemplateResolver.ResolveAllMembers`, and
|
||||||
|
`CollisionDetector.DetectCollisions` — so a list containing two templates with the
|
||||||
|
same `Id` (e.g. not-yet-saved templates carrying `Id 0`) no longer throws an
|
||||||
|
unhandled `ArgumentException`. Separately, the `0`-as-"no-parent" sentinel was
|
||||||
|
removed: `DetectInheritanceCycle` now walks the parent chain via the `int?`
|
||||||
|
`ParentTemplateId` (`HasValue` gates continuation), and `DetectCrossGraphCycle`
|
||||||
|
gates the proposed parent/composed edges on `HasValue` rather than `!= 0`, so a
|
||||||
|
template with a real `Id` of 0 is treated like any other node and cycles through it
|
||||||
|
are detected. Regression tests:
|
||||||
|
`CycleDetectorTests.DetectInheritanceCycle_DuplicateIdsInList_DoesNotThrow`,
|
||||||
|
`DetectCompositionCycle_DuplicateIdsInList_DoesNotThrow`,
|
||||||
|
`DetectCrossGraphCycle_DuplicateIdsInList_DoesNotThrow`,
|
||||||
|
`DetectInheritanceCycle_RealIdZero_StillDetectsCycle`,
|
||||||
|
`DetectInheritanceCycle_ParentChainThroughIdZero_DetectsCycle`.
|
||||||
|
|
||||||
### TemplateEngine-014 — Template-deletion constraint logic is duplicated and divergent
|
### TemplateEngine-014 — Template-deletion constraint logic is duplicated and divergent
|
||||||
|
|
||||||
@@ -564,7 +612,7 @@ _Unresolved._
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Low |
|
| Severity | Low |
|
||||||
| Category | Code organization & conventions |
|
| Category | Code organization & conventions |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.TemplateEngine/TemplateService.cs:109`, `src/ScadaLink.TemplateEngine/Services/TemplateDeletionService.cs:27` |
|
| Location | `src/ScadaLink.TemplateEngine/TemplateService.cs:109`, `src/ScadaLink.TemplateEngine/Services/TemplateDeletionService.cs:27` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -586,4 +634,17 @@ vice versa) so the constraint logic lives in exactly one place.
|
|||||||
|
|
||||||
**Resolution**
|
**Resolution**
|
||||||
|
|
||||||
_Unresolved._
|
Resolved 2026-05-16 (commit `pending commit`): `TemplateService.DeleteTemplateAsync`
|
||||||
|
no longer re-implements the deletion-constraint rules — it now delegates the
|
||||||
|
constraint check and the delete to `TemplateDeletionService.DeleteTemplateAsync`
|
||||||
|
(the surviving single implementation, which already accumulates every blocking
|
||||||
|
reason rather than returning on the first failing category). `TemplateService`
|
||||||
|
retains only its audit-logging side effect: after a successful delete it writes the
|
||||||
|
`Delete` audit entry and calls `SaveChangesAsync` (the deletion service is unaware of
|
||||||
|
auditing and persists the delete itself, so the audit entry needs its own save).
|
||||||
|
The constraint logic now lives in exactly one place, so a future rule change cannot
|
||||||
|
drift between two implementations. Behavioural change: `DeleteTemplateAsync` now
|
||||||
|
reports all blocking reasons and uses `TemplateDeletionService`'s phrasing — the
|
||||||
|
affected `TemplateServiceTests` delete tests were updated to the unified messages,
|
||||||
|
and a regression test `DeleteTemplate_MultipleConstraints_ReportsAllNotJustFirst`
|
||||||
|
verifies all three constraint categories are surfaced together.
|
||||||
|
|||||||
@@ -6,14 +6,20 @@ namespace ScadaLink.SiteEventLogging;
|
|||||||
public interface ISiteEventLogger
|
public interface ISiteEventLogger
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Record an event asynchronously.
|
/// Record an event asynchronously. The call enqueues the event onto a background
|
||||||
|
/// writer and returns without blocking the caller on disk I/O. The returned
|
||||||
|
/// <see cref="Task"/> completes once the event is durably persisted and faults if
|
||||||
|
/// the write fails, so callers that <c>await</c> it observe success or failure.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="eventType">Category: script, alarm, deployment, connection, store_and_forward, instance_lifecycle</param>
|
/// <param name="eventType">Category: script, alarm, deployment, connection, store_and_forward, instance_lifecycle</param>
|
||||||
/// <param name="severity">Info, Warning, or Error</param>
|
/// <param name="severity">Info, Warning, or Error</param>
|
||||||
/// <param name="instanceId">Optional instance ID associated with the event</param>
|
/// <param name="instanceId">Optional instance ID associated with the event</param>
|
||||||
/// <param name="source">Source identifier, e.g., "ScriptActor:MonitorSpeed"</param>
|
/// <param name="source">Source identifier, e.g., "ScriptActor:MonitorSpeed"</param>
|
||||||
/// <param name="message">Human-readable event description</param>
|
/// <param name="message">Human-readable event description</param>
|
||||||
/// <param name="details">Optional JSON details (stack traces, compilation errors, etc.)</param>
|
/// <param name="details">
|
||||||
|
/// Optional free-form detail text (stack traces, compilation errors, etc.).
|
||||||
|
/// Stored verbatim — JSON is conventional but not validated or enforced.
|
||||||
|
/// </param>
|
||||||
Task LogEventAsync(
|
Task LogEventAsync(
|
||||||
string eventType,
|
string eventType,
|
||||||
string severity,
|
string severity,
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ public static class ServiceCollectionExtensions
|
|||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IServiceCollection AddSiteEventLoggingActors(this IServiceCollection services)
|
// NOTE: EventLogHandlerActor is wired up directly in
|
||||||
{
|
// ScadaLink.Host/Actors/AkkaHostedService.cs as a cluster singleton, because the
|
||||||
// Placeholder for Akka actor registration (Phase 4+)
|
// actor must be created inside the ActorSystem with the resolved
|
||||||
return services;
|
// IEventLogQueryService. There is intentionally no DI helper for that here — a
|
||||||
}
|
// former AddSiteEventLoggingActors placeholder was dead code and has been removed.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,7 +123,12 @@ public class SiteEventLogger : ISiteEventLogger, IDisposable
|
|||||||
CREATE INDEX IF NOT EXISTS idx_events_timestamp ON site_events(timestamp);
|
CREATE INDEX IF NOT EXISTS idx_events_timestamp ON site_events(timestamp);
|
||||||
CREATE INDEX IF NOT EXISTS idx_events_type ON site_events(event_type);
|
CREATE INDEX IF NOT EXISTS idx_events_type ON site_events(event_type);
|
||||||
CREATE INDEX IF NOT EXISTS idx_events_instance ON site_events(instance_id);
|
CREATE INDEX IF NOT EXISTS idx_events_instance ON site_events(instance_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_severity ON site_events(severity);
|
||||||
""";
|
""";
|
||||||
|
// The query service also supports keyword search via leading-wildcard
|
||||||
|
// LIKE on message/source. A leading-wildcard LIKE cannot use a B-tree
|
||||||
|
// index, so that path intentionally full-scans; severity/event_type/
|
||||||
|
// instance_id/timestamp filters above are all covered.
|
||||||
cmd.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
private readonly SiteStreamManager? _streamManager;
|
private readonly SiteStreamManager? _streamManager;
|
||||||
private readonly SiteRuntimeOptions _options;
|
private readonly SiteRuntimeOptions _options;
|
||||||
private readonly ILogger<DeploymentManagerActor> _logger;
|
private readonly ILogger<DeploymentManagerActor> _logger;
|
||||||
|
/// <summary>
|
||||||
|
/// Shared logger factory used to mint <see cref="InstanceActor"/> loggers
|
||||||
|
/// (SiteRuntime-015). Reused across every <see cref="CreateInstanceActor"/>
|
||||||
|
/// call rather than newing a per-instance factory that is never disposed.
|
||||||
|
/// When the host injects its configured factory the Instance Actor logs are
|
||||||
|
/// routed through the application's logging providers.
|
||||||
|
/// </summary>
|
||||||
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
private readonly IActorRef? _dclManager;
|
private readonly IActorRef? _dclManager;
|
||||||
private readonly IActorRef? _replicationActor;
|
private readonly IActorRef? _replicationActor;
|
||||||
private readonly ISiteHealthCollector? _healthCollector;
|
private readonly ISiteHealthCollector? _healthCollector;
|
||||||
@@ -59,7 +67,8 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
IActorRef? dclManager = null,
|
IActorRef? dclManager = null,
|
||||||
IActorRef? replicationActor = null,
|
IActorRef? replicationActor = null,
|
||||||
ISiteHealthCollector? healthCollector = null,
|
ISiteHealthCollector? healthCollector = null,
|
||||||
IServiceProvider? serviceProvider = null)
|
IServiceProvider? serviceProvider = null,
|
||||||
|
ILoggerFactory? loggerFactory = null)
|
||||||
{
|
{
|
||||||
_storage = storage;
|
_storage = storage;
|
||||||
_compilationService = compilationService;
|
_compilationService = compilationService;
|
||||||
@@ -71,6 +80,13 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
_healthCollector = healthCollector;
|
_healthCollector = healthCollector;
|
||||||
_serviceProvider = serviceProvider;
|
_serviceProvider = serviceProvider;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
// SiteRuntime-015: reuse a single logger factory for all Instance Actors.
|
||||||
|
// Prefer an explicitly injected factory, fall back to one resolved from
|
||||||
|
// the service provider, and only as a last resort use NullLoggerFactory —
|
||||||
|
// never a per-instance `new LoggerFactory()` that leaks undisposed.
|
||||||
|
_loggerFactory = loggerFactory
|
||||||
|
?? serviceProvider?.GetService(typeof(ILoggerFactory)) as ILoggerFactory
|
||||||
|
?? Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance;
|
||||||
|
|
||||||
// Lifecycle commands
|
// Lifecycle commands
|
||||||
Receive<DeployInstanceCommand>(HandleDeploy);
|
Receive<DeployInstanceCommand>(HandleDeploy);
|
||||||
@@ -942,7 +958,8 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var loggerFactory = new LoggerFactory();
|
// SiteRuntime-015: reuse the shared, host-configured logger factory
|
||||||
|
// instead of allocating (and leaking) a fresh LoggerFactory per instance.
|
||||||
var props = Props.Create(() => new InstanceActor(
|
var props = Props.Create(() => new InstanceActor(
|
||||||
instanceName,
|
instanceName,
|
||||||
configJson,
|
configJson,
|
||||||
@@ -951,7 +968,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
_sharedScriptLibrary,
|
_sharedScriptLibrary,
|
||||||
_streamManager,
|
_streamManager,
|
||||||
_options,
|
_options,
|
||||||
loggerFactory.CreateLogger<InstanceActor>(),
|
_loggerFactory.CreateLogger<InstanceActor>(),
|
||||||
_dclManager,
|
_dclManager,
|
||||||
_healthCollector,
|
_healthCollector,
|
||||||
_serviceProvider));
|
_serviceProvider));
|
||||||
|
|||||||
@@ -494,12 +494,19 @@ public class InstanceActor : ReceiveActor
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// WP-25: Debug view unsubscribe — removes subscription.
|
/// WP-25: Debug view unsubscribe (SiteRuntime-013).
|
||||||
|
/// This handler is a deliberate no-op acknowledgement: the Instance Actor holds
|
||||||
|
/// no per-subscriber state. The real debug-stream subscription lifecycle lives in
|
||||||
|
/// <see cref="ScadaLink.SiteRuntime.Streaming.SiteStreamManager"/>
|
||||||
|
/// (Subscribe / Unsubscribe / RemoveSubscriber); the gRPC stream is torn down
|
||||||
|
/// there when the central side cancels the call. Nothing is removed here.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void HandleUnsubscribeDebugView(UnsubscribeDebugViewRequest request)
|
private void HandleUnsubscribeDebugView(UnsubscribeDebugViewRequest request)
|
||||||
{
|
{
|
||||||
|
// No subscription state in the Instance Actor — see the XML doc above.
|
||||||
_logger.LogDebug(
|
_logger.LogDebug(
|
||||||
"Debug view unsubscribe for {Instance}, correlationId={Id}",
|
"Debug view unsubscribe for {Instance}, correlationId={Id} " +
|
||||||
|
"(no-op; subscription teardown handled by SiteStreamManager)",
|
||||||
_instanceUniqueName, request.CorrelationId);
|
_instanceUniqueName, request.CorrelationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,18 @@ namespace ScadaLink.SiteRuntime.Scripts;
|
|||||||
/// Scope-aware view onto the instance's attributes, anchored at a path prefix.
|
/// Scope-aware view onto the instance's attributes, anchored at a path prefix.
|
||||||
/// <c>Attributes["X"]</c> on the root scope resolves to canonical name "X";
|
/// <c>Attributes["X"]</c> on the root scope resolves to canonical name "X";
|
||||||
/// on a composition with prefix "TempSensor" it resolves to "TempSensor.X".
|
/// on a composition with prefix "TempSensor" it resolves to "TempSensor.X".
|
||||||
/// Reads block on the actor Ask; async variants are provided for callers
|
///
|
||||||
/// that prefer to await explicitly.
|
/// <para>
|
||||||
|
/// Thread-model note (SiteRuntime-012): the indexer get/set block synchronously
|
||||||
|
/// on the Instance Actor Ask (and, for data-connected attributes, the DCL
|
||||||
|
/// round-trip). This is safe because script bodies execute on the dedicated
|
||||||
|
/// <see cref="ScriptExecutionScheduler"/> threads (SiteRuntime-009), not the
|
||||||
|
/// shared <see cref="System.Threading.ThreadPool"/> — so a blocked accessor
|
||||||
|
/// cannot starve unrelated Akka dispatchers or HTTP request handling. The async
|
||||||
|
/// variants (<see cref="GetAsync"/>/<see cref="SetAsync"/>) are still preferred
|
||||||
|
/// where the script can await, as they avoid holding a dedicated thread idle for
|
||||||
|
/// the duration of each round-trip.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AttributeAccessor
|
public class AttributeAccessor
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -31,12 +31,15 @@ public class ParkedMessageHandlerActor : ReceiveActor
|
|||||||
var sender = Sender;
|
var sender = Sender;
|
||||||
var siteId = _siteId;
|
var siteId = _siteId;
|
||||||
|
|
||||||
|
// StoreAndForward-007: idiomatic PipeTo with explicit success/failure
|
||||||
|
// projections instead of ContinueWith. Both projections touch only locals
|
||||||
|
// (captured before the await), so they are safe to run off the actor thread.
|
||||||
_service.GetParkedMessagesAsync(category: null, msg.PageNumber, msg.PageSize)
|
_service.GetParkedMessagesAsync(category: null, msg.PageNumber, msg.PageSize)
|
||||||
.ContinueWith(t =>
|
.PipeTo(
|
||||||
{
|
sender,
|
||||||
if (t.IsCompletedSuccessfully)
|
success: result =>
|
||||||
{
|
{
|
||||||
var entries = t.Result.Messages
|
var entries = result.Messages
|
||||||
.Select(m => new ParkedMessageEntry(
|
.Select(m => new ParkedMessageEntry(
|
||||||
MessageId: m.Id,
|
MessageId: m.Id,
|
||||||
TargetSystem: m.Target,
|
TargetSystem: m.Target,
|
||||||
@@ -51,14 +54,12 @@ public class ParkedMessageHandlerActor : ReceiveActor
|
|||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return new ParkedMessageQueryResponse(
|
return new ParkedMessageQueryResponse(
|
||||||
msg.CorrelationId, siteId, entries, t.Result.TotalCount,
|
msg.CorrelationId, siteId, entries, result.TotalCount,
|
||||||
msg.PageNumber, msg.PageSize, true, null, DateTimeOffset.UtcNow);
|
msg.PageNumber, msg.PageSize, true, null, DateTimeOffset.UtcNow);
|
||||||
}
|
},
|
||||||
|
failure: ex => new ParkedMessageQueryResponse(
|
||||||
return new ParkedMessageQueryResponse(
|
|
||||||
msg.CorrelationId, siteId, [], 0, msg.PageNumber, msg.PageSize,
|
msg.CorrelationId, siteId, [], 0, msg.PageNumber, msg.PageSize,
|
||||||
false, t.Exception?.GetBaseException().Message, DateTimeOffset.UtcNow);
|
false, ex.GetBaseException().Message, DateTimeOffset.UtcNow));
|
||||||
}).PipeTo(sender);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HandleRetry(ParkedMessageRetryRequest msg)
|
private void HandleRetry(ParkedMessageRetryRequest msg)
|
||||||
@@ -66,18 +67,13 @@ public class ParkedMessageHandlerActor : ReceiveActor
|
|||||||
var sender = Sender;
|
var sender = Sender;
|
||||||
|
|
||||||
_service.RetryParkedMessageAsync(msg.MessageId)
|
_service.RetryParkedMessageAsync(msg.MessageId)
|
||||||
.ContinueWith(t =>
|
.PipeTo(
|
||||||
{
|
sender,
|
||||||
if (t.IsCompletedSuccessfully)
|
success: retried => new ParkedMessageRetryResponse(
|
||||||
{
|
msg.CorrelationId, retried,
|
||||||
return new ParkedMessageRetryResponse(
|
retried ? null : "Message not found or no longer parked."),
|
||||||
msg.CorrelationId, t.Result,
|
failure: ex => new ParkedMessageRetryResponse(
|
||||||
t.Result ? null : "Message not found or no longer parked.");
|
msg.CorrelationId, false, ex.GetBaseException().Message));
|
||||||
}
|
|
||||||
|
|
||||||
return new ParkedMessageRetryResponse(
|
|
||||||
msg.CorrelationId, false, t.Exception?.GetBaseException().Message);
|
|
||||||
}).PipeTo(sender);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HandleDiscard(ParkedMessageDiscardRequest msg)
|
private void HandleDiscard(ParkedMessageDiscardRequest msg)
|
||||||
@@ -85,18 +81,13 @@ public class ParkedMessageHandlerActor : ReceiveActor
|
|||||||
var sender = Sender;
|
var sender = Sender;
|
||||||
|
|
||||||
_service.DiscardParkedMessageAsync(msg.MessageId)
|
_service.DiscardParkedMessageAsync(msg.MessageId)
|
||||||
.ContinueWith(t =>
|
.PipeTo(
|
||||||
{
|
sender,
|
||||||
if (t.IsCompletedSuccessfully)
|
success: discarded => new ParkedMessageDiscardResponse(
|
||||||
{
|
msg.CorrelationId, discarded,
|
||||||
return new ParkedMessageDiscardResponse(
|
discarded ? null : "Message not found or no longer parked."),
|
||||||
msg.CorrelationId, t.Result,
|
failure: ex => new ParkedMessageDiscardResponse(
|
||||||
t.Result ? null : "Message not found or no longer parked.");
|
msg.CorrelationId, false, ex.GetBaseException().Message));
|
||||||
}
|
|
||||||
|
|
||||||
return new ParkedMessageDiscardResponse(
|
|
||||||
msg.CorrelationId, false, t.Exception?.GetBaseException().Message);
|
|
||||||
}).PipeTo(sender);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ExtractMethodName(string payloadJson, Commons.Types.Enums.StoreAndForwardCategory category)
|
private static string ExtractMethodName(string payloadJson, Commons.Types.Enums.StoreAndForwardCategory category)
|
||||||
|
|||||||
@@ -377,9 +377,34 @@ public class StoreAndForwardService
|
|||||||
return await _storage.GetMessageCountByOriginInstanceAsync(instanceName);
|
return await _storage.GetMessageCountByOriginInstanceAsync(instanceName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WP-14: Raises the S&F activity notification. StoreAndForward-009: the
|
||||||
|
/// delegate is snapshotted (so a concurrent unsubscribe cannot NRE) and every
|
||||||
|
/// subscriber invocation is wrapped so a slow/throwing subscriber (e.g. the site
|
||||||
|
/// event log) cannot abort the caller. Crucially, a subscriber exception raised
|
||||||
|
/// from <see cref="EnqueueAsync"/> or <c>RetryMessageAsync</c> must NOT be
|
||||||
|
/// misclassified as a transient delivery failure — pre-fix it escaped into the
|
||||||
|
/// delivery try/catch and caused a successfully delivered message to be buffered
|
||||||
|
/// (or its retry count to be bumped). Activity logging is best-effort.
|
||||||
|
/// </summary>
|
||||||
private void RaiseActivity(string action, StoreAndForwardCategory category, string detail)
|
private void RaiseActivity(string action, StoreAndForwardCategory category, string detail)
|
||||||
{
|
{
|
||||||
OnActivity?.Invoke(action, category, detail);
|
var handlers = OnActivity;
|
||||||
|
if (handlers == null) return;
|
||||||
|
|
||||||
|
foreach (var handler in handlers.GetInvocationList().Cast<Action<string, StoreAndForwardCategory, string>>())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
handler(action, category, detail);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex,
|
||||||
|
"Store-and-forward activity subscriber threw for action {Action}; ignored",
|
||||||
|
action);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,19 @@ namespace ScadaLink.StoreAndForward;
|
|||||||
/// WP-9: SQLite persistence layer for store-and-forward messages.
|
/// WP-9: SQLite persistence layer for store-and-forward messages.
|
||||||
/// Uses direct Microsoft.Data.Sqlite (not EF Core) for lightweight site-side storage.
|
/// Uses direct Microsoft.Data.Sqlite (not EF Core) for lightweight site-side storage.
|
||||||
/// No max buffer size per design decision.
|
/// No max buffer size per design decision.
|
||||||
|
///
|
||||||
|
/// StoreAndForward-008: every method opens a fresh <see cref="SqliteConnection"/> for
|
||||||
|
/// the duration of the call rather than holding a long-lived connection. This is a
|
||||||
|
/// deliberate trade-off, not an oversight: Microsoft.Data.Sqlite maintains an internal
|
||||||
|
/// connection pool keyed on the connection string, so <c>OpenAsync</c> on a previously
|
||||||
|
/// used connection string reuses a pooled handle instead of performing a real file
|
||||||
|
/// open. The retry sweep therefore relies on that pool for acceptable performance —
|
||||||
|
/// it calls <see cref="RemoveMessageAsync"/> / <see cref="UpdateMessageIfStatusAsync"/>
|
||||||
|
/// once per due message, and with no max buffer size (by design) the buffer can grow
|
||||||
|
/// large. The connection-per-call style keeps each method self-contained and
|
||||||
|
/// transaction-scoped; if profiling ever shows the pooled open to be a bottleneck on
|
||||||
|
/// the hot retry path, the remedy is a batched sweep API that opens one connection (and
|
||||||
|
/// one transaction) per sweep.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class StoreAndForwardStorage
|
public class StoreAndForwardStorage
|
||||||
{
|
{
|
||||||
@@ -222,6 +235,12 @@ public class StoreAndForwardStorage
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// WP-12: Gets all parked messages, optionally filtered by category, with pagination.
|
/// WP-12: Gets all parked messages, optionally filtered by category, with pagination.
|
||||||
|
///
|
||||||
|
/// StoreAndForward-006: the COUNT(*) and the paged SELECT run inside a single
|
||||||
|
/// transaction so they observe one consistent snapshot. Without it, a concurrent
|
||||||
|
/// enqueue/park/discard arriving between the two statements yields a TotalCount
|
||||||
|
/// inconsistent with the returned page (flickering totals / off-by-one page math
|
||||||
|
/// in the paginated UI).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<(List<StoreAndForwardMessage> Messages, int TotalCount)> GetParkedMessagesAsync(
|
public async Task<(List<StoreAndForwardMessage> Messages, int TotalCount)> GetParkedMessagesAsync(
|
||||||
StoreAndForwardCategory? category = null,
|
StoreAndForwardCategory? category = null,
|
||||||
@@ -231,8 +250,11 @@ public class StoreAndForwardStorage
|
|||||||
await using var connection = new SqliteConnection(_connectionString);
|
await using var connection = new SqliteConnection(_connectionString);
|
||||||
await connection.OpenAsync();
|
await connection.OpenAsync();
|
||||||
|
|
||||||
|
await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync();
|
||||||
|
|
||||||
// Count
|
// Count
|
||||||
await using var countCmd = connection.CreateCommand();
|
await using var countCmd = connection.CreateCommand();
|
||||||
|
countCmd.Transaction = transaction;
|
||||||
countCmd.CommandText = category.HasValue
|
countCmd.CommandText = category.HasValue
|
||||||
? "SELECT COUNT(*) FROM sf_messages WHERE status = @parked AND category = @category"
|
? "SELECT COUNT(*) FROM sf_messages WHERE status = @parked AND category = @category"
|
||||||
: "SELECT COUNT(*) FROM sf_messages WHERE status = @parked";
|
: "SELECT COUNT(*) FROM sf_messages WHERE status = @parked";
|
||||||
@@ -242,6 +264,7 @@ public class StoreAndForwardStorage
|
|||||||
|
|
||||||
// Page
|
// Page
|
||||||
await using var pageCmd = connection.CreateCommand();
|
await using var pageCmd = connection.CreateCommand();
|
||||||
|
pageCmd.Transaction = transaction;
|
||||||
var categoryFilter = category.HasValue ? " AND category = @category" : "";
|
var categoryFilter = category.HasValue ? " AND category = @category" : "";
|
||||||
pageCmd.CommandText = $@"
|
pageCmd.CommandText = $@"
|
||||||
SELECT id, category, target, payload_json, retry_count, max_retries,
|
SELECT id, category, target, payload_json, retry_count, max_retries,
|
||||||
@@ -257,6 +280,8 @@ public class StoreAndForwardStorage
|
|||||||
pageCmd.Parameters.AddWithValue("@offset", (pageNumber - 1) * pageSize);
|
pageCmd.Parameters.AddWithValue("@offset", (pageNumber - 1) * pageSize);
|
||||||
|
|
||||||
var messages = await ReadMessagesAsync(pageCmd);
|
var messages = await ReadMessagesAsync(pageCmd);
|
||||||
|
|
||||||
|
await transaction.CommitAsync();
|
||||||
return (messages, totalCount);
|
return (messages, totalCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ public static class CollisionDetector
|
|||||||
Template template,
|
Template template,
|
||||||
IReadOnlyList<Template> allTemplates)
|
IReadOnlyList<Template> allTemplates)
|
||||||
{
|
{
|
||||||
var lookup = allTemplates.ToDictionary(t => t.Id);
|
// Duplicate-tolerant lookup (see CycleDetector.BuildLookup): a plain
|
||||||
|
// ToDictionary(t => t.Id) throws if two templates share an Id (e.g.
|
||||||
|
// not-yet-saved templates carrying Id 0).
|
||||||
|
var lookup = CycleDetector.BuildLookup(allTemplates);
|
||||||
var allMembers = new List<ResolvedMember>();
|
var allMembers = new List<ResolvedMember>();
|
||||||
|
|
||||||
// Collect direct (top-level) members
|
// Collect direct (top-level) members
|
||||||
|
|||||||
@@ -9,6 +9,21 @@ namespace ScadaLink.TemplateEngine;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class CycleDetector
|
public static class CycleDetector
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builds an Id-keyed lookup that tolerates duplicate Ids in the input list
|
||||||
|
/// (e.g. multiple not-yet-saved templates all carrying Id 0). On a duplicate
|
||||||
|
/// the first occurrence wins — graph walks only need one representative node
|
||||||
|
/// per Id, and a real cycle through any duplicate would still be reachable.
|
||||||
|
/// A plain <c>ToDictionary(t => t.Id)</c> would instead throw ArgumentException.
|
||||||
|
/// </summary>
|
||||||
|
internal static Dictionary<int, Template> BuildLookup(IReadOnlyList<Template> allTemplates)
|
||||||
|
{
|
||||||
|
var lookup = new Dictionary<int, Template>();
|
||||||
|
foreach (var t in allTemplates)
|
||||||
|
lookup.TryAdd(t.Id, t);
|
||||||
|
return lookup;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks whether setting <paramref name="parentId"/> as the parent of template
|
/// Checks whether setting <paramref name="parentId"/> as the parent of template
|
||||||
/// <paramref name="templateId"/> would introduce an inheritance cycle.
|
/// <paramref name="templateId"/> would introduce an inheritance cycle.
|
||||||
@@ -27,28 +42,30 @@ public static class CycleDetector
|
|||||||
|
|
||||||
// Walk the inheritance chain from the proposed parent upward.
|
// Walk the inheritance chain from the proposed parent upward.
|
||||||
// If we arrive back at templateId, there is a cycle.
|
// If we arrive back at templateId, there is a cycle.
|
||||||
var lookup = allTemplates.ToDictionary(t => t.Id);
|
var lookup = BuildLookup(allTemplates);
|
||||||
var visited = new HashSet<int> { templateId };
|
var visited = new HashSet<int> { templateId };
|
||||||
var chain = new List<string>();
|
var chain = new List<string>();
|
||||||
|
|
||||||
var templateName = lookup.TryGetValue(templateId, out var tmpl) ? tmpl.Name : templateId.ToString();
|
var templateName = lookup.TryGetValue(templateId, out var tmpl) ? tmpl.Name : templateId.ToString();
|
||||||
chain.Add(templateName);
|
chain.Add(templateName);
|
||||||
|
|
||||||
var currentId = parentId;
|
// ParentTemplateId is int? — a missing value (not 0) means "no parent",
|
||||||
while (currentId != 0)
|
// so a template with a real Id of 0 is walked like any other node.
|
||||||
|
int? currentId = parentId;
|
||||||
|
while (currentId.HasValue)
|
||||||
{
|
{
|
||||||
if (!lookup.TryGetValue(currentId, out var current))
|
if (!lookup.TryGetValue(currentId.Value, out var current))
|
||||||
break;
|
break;
|
||||||
|
|
||||||
chain.Add(current.Name);
|
chain.Add(current.Name);
|
||||||
|
|
||||||
if (visited.Contains(currentId))
|
if (visited.Contains(currentId.Value))
|
||||||
{
|
{
|
||||||
return $"Inheritance cycle detected: {string.Join(" -> ", chain)}.";
|
return $"Inheritance cycle detected: {string.Join(" -> ", chain)}.";
|
||||||
}
|
}
|
||||||
|
|
||||||
visited.Add(currentId);
|
visited.Add(currentId.Value);
|
||||||
currentId = current.ParentTemplateId ?? 0;
|
currentId = current.ParentTemplateId;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -70,7 +87,7 @@ public static class CycleDetector
|
|||||||
return $"Template '{selfName}' cannot compose itself.";
|
return $"Template '{selfName}' cannot compose itself.";
|
||||||
}
|
}
|
||||||
|
|
||||||
var lookup = allTemplates.ToDictionary(t => t.Id);
|
var lookup = BuildLookup(allTemplates);
|
||||||
|
|
||||||
// BFS/DFS from composedTemplateId through all its compositions.
|
// BFS/DFS from composedTemplateId through all its compositions.
|
||||||
// If we reach templateId, that's a cycle.
|
// If we reach templateId, that's a cycle.
|
||||||
@@ -115,7 +132,7 @@ public static class CycleDetector
|
|||||||
int? proposedComposedTemplateId,
|
int? proposedComposedTemplateId,
|
||||||
IReadOnlyList<Template> allTemplates)
|
IReadOnlyList<Template> allTemplates)
|
||||||
{
|
{
|
||||||
var lookup = allTemplates.ToDictionary(t => t.Id);
|
var lookup = BuildLookup(allTemplates);
|
||||||
|
|
||||||
// Build adjacency: for each template, collect all reachable templates
|
// Build adjacency: for each template, collect all reachable templates
|
||||||
// via inheritance (parent) and composition edges.
|
// via inheritance (parent) and composition edges.
|
||||||
@@ -124,11 +141,12 @@ public static class CycleDetector
|
|||||||
var visited = new HashSet<int>();
|
var visited = new HashSet<int>();
|
||||||
var queue = new Queue<int>();
|
var queue = new Queue<int>();
|
||||||
|
|
||||||
// Seed with proposed targets
|
// Seed with proposed targets. A null proposed id means "no edge"; a value
|
||||||
if (proposedParentId.HasValue && proposedParentId.Value != 0)
|
// of 0 is a legitimate Id, so only HasValue gates enqueuing.
|
||||||
|
if (proposedParentId.HasValue)
|
||||||
queue.Enqueue(proposedParentId.Value);
|
queue.Enqueue(proposedParentId.Value);
|
||||||
|
|
||||||
if (proposedComposedTemplateId.HasValue && proposedComposedTemplateId.Value != 0)
|
if (proposedComposedTemplateId.HasValue)
|
||||||
queue.Enqueue(proposedComposedTemplateId.Value);
|
queue.Enqueue(proposedComposedTemplateId.Value);
|
||||||
|
|
||||||
while (queue.Count > 0)
|
while (queue.Count > 0)
|
||||||
@@ -146,8 +164,8 @@ public static class CycleDetector
|
|||||||
if (!lookup.TryGetValue(currentId, out var current))
|
if (!lookup.TryGetValue(currentId, out var current))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// Follow inheritance edge
|
// Follow inheritance edge (int? — missing value means no parent)
|
||||||
if (current.ParentTemplateId.HasValue && current.ParentTemplateId.Value != 0)
|
if (current.ParentTemplateId.HasValue)
|
||||||
queue.Enqueue(current.ParentTemplateId.Value);
|
queue.Enqueue(current.ParentTemplateId.Value);
|
||||||
|
|
||||||
// Follow composition edges
|
// Follow composition edges
|
||||||
|
|||||||
@@ -8,19 +8,24 @@ namespace ScadaLink.TemplateEngine.Flattening;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Produces a deterministic SHA-256 hash of a FlattenedConfiguration.
|
/// Produces a deterministic SHA-256 hash of a FlattenedConfiguration.
|
||||||
/// Same content always produces the same hash across runs by using
|
/// Same content always produces the same hash across runs.
|
||||||
/// canonical JSON serialization with sorted keys.
|
/// <para>
|
||||||
|
/// DETERMINISM CONTRACT: System.Text.Json serializes properties in CLR
|
||||||
|
/// declaration order, which it does NOT sort. Stable output therefore relies
|
||||||
|
/// on the private <c>Hashable*</c> records below declaring their properties
|
||||||
|
/// in alphabetical order. A contributor adding a property out of alphabetical
|
||||||
|
/// order would silently change every revision hash; the ordering is guarded
|
||||||
|
/// by <c>RevisionHashServiceTests.HashableRecords_PropertiesDeclaredAlphabetically</c>.
|
||||||
|
/// Collections are explicitly sorted by <c>CanonicalName</c> before hashing.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class RevisionHashService
|
public class RevisionHashService
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
|
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
|
||||||
{
|
{
|
||||||
// Sort properties alphabetically for determinism
|
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
WriteIndented = false,
|
WriteIndented = false
|
||||||
// Ensure consistent ordering
|
|
||||||
Converters = { new SortedPropertiesConverterFactory() }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -84,7 +89,9 @@ public class RevisionHashService
|
|||||||
return $"sha256:{Convert.ToHexStringLower(hashBytes)}";
|
return $"sha256:{Convert.ToHexStringLower(hashBytes)}";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Internal types for deterministic serialization (sorted property names via camelCase + alphabetical)
|
// Internal types for deterministic serialization. Properties MUST be declared
|
||||||
|
// in alphabetical order — System.Text.Json emits them in declaration order and
|
||||||
|
// does not sort. See the DETERMINISM CONTRACT note on the class summary.
|
||||||
private sealed record HashableConfiguration
|
private sealed record HashableConfiguration
|
||||||
{
|
{
|
||||||
public List<HashableAlarm> Alarms { get; init; } = [];
|
public List<HashableAlarm> Alarms { get; init; } = [];
|
||||||
@@ -128,14 +135,3 @@ public class RevisionHashService
|
|||||||
public string? TriggerType { get; init; }
|
public string? TriggerType { get; init; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A JSON converter factory that ensures properties are serialized in alphabetical order
|
|
||||||
/// for deterministic output. Works with record types.
|
|
||||||
/// </summary>
|
|
||||||
internal class SortedPropertiesConverterFactory : JsonConverterFactory
|
|
||||||
{
|
|
||||||
public override bool CanConvert(Type typeToConvert) => false;
|
|
||||||
|
|
||||||
public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) => null;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ public static class TemplateResolver
|
|||||||
int templateId,
|
int templateId,
|
||||||
IReadOnlyList<Template> allTemplates)
|
IReadOnlyList<Template> allTemplates)
|
||||||
{
|
{
|
||||||
var lookup = allTemplates.ToDictionary(t => t.Id);
|
// Duplicate-tolerant lookup (see CycleDetector.BuildLookup): a plain
|
||||||
|
// ToDictionary(t => t.Id) throws if two templates share an Id (e.g.
|
||||||
|
// not-yet-saved templates carrying Id 0).
|
||||||
|
var lookup = CycleDetector.BuildLookup(allTemplates);
|
||||||
if (!lookup.TryGetValue(templateId, out var template))
|
if (!lookup.TryGetValue(templateId, out var template))
|
||||||
return Array.Empty<ResolvedTemplateMember>();
|
return Array.Empty<ResolvedTemplateMember>();
|
||||||
|
|
||||||
|
|||||||
@@ -112,59 +112,18 @@ public class TemplateService
|
|||||||
if (template == null)
|
if (template == null)
|
||||||
return Result<bool>.Failure($"Template with ID {templateId} not found.");
|
return Result<bool>.Failure($"Template with ID {templateId} not found.");
|
||||||
|
|
||||||
// Derived templates are owned by their composition row and must be removed
|
// Deletion-constraint logic (instances / child / derived / composing
|
||||||
// by deleting the composition (which cascades) — block direct deletion.
|
// templates) lives in exactly one place — TemplateDeletionService — so a
|
||||||
if (template.IsDerived)
|
// future rule change cannot drift between two implementations
|
||||||
return Result<bool>.Failure(
|
// (TemplateEngine-014). TemplateService owns only the audit-logging side
|
||||||
$"Cannot delete template '{template.Name}': it is a derived template. " +
|
// effect, which the deletion service is unaware of.
|
||||||
"Remove the owning composition on its parent template instead.");
|
var deletionService = new Services.TemplateDeletionService(_repository);
|
||||||
|
var deleteResult = await deletionService.DeleteTemplateAsync(templateId, cancellationToken);
|
||||||
|
if (deleteResult.IsFailure)
|
||||||
|
return deleteResult;
|
||||||
|
|
||||||
// Check for instances referencing this template
|
// TemplateDeletionService already persisted the delete; the audit entry
|
||||||
var instances = await _repository.GetInstancesByTemplateIdAsync(templateId, cancellationToken);
|
// is added to the change tracker here and needs its own SaveChangesAsync.
|
||||||
if (instances.Count > 0)
|
|
||||||
return Result<bool>.Failure(
|
|
||||||
$"Cannot delete template '{template.Name}': it is referenced by {instances.Count} instance(s).");
|
|
||||||
|
|
||||||
// Check for child templates inheriting from this template.
|
|
||||||
// Split derived vs. regular children — the message and remediation differ.
|
|
||||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
|
||||||
var inheritors = allTemplates.Where(t => t.ParentTemplateId == templateId).ToList();
|
|
||||||
var derivatives = inheritors.Where(t => t.IsDerived).ToList();
|
|
||||||
var regularChildren = inheritors.Where(t => !t.IsDerived).ToList();
|
|
||||||
|
|
||||||
if (regularChildren.Count > 0)
|
|
||||||
return Result<bool>.Failure(
|
|
||||||
$"Cannot delete template '{template.Name}': it is inherited by {regularChildren.Count} child template(s): " +
|
|
||||||
string.Join(", ", regularChildren.Select(c => $"'{c.Name}'")));
|
|
||||||
|
|
||||||
if (derivatives.Count > 0)
|
|
||||||
{
|
|
||||||
// Name each derivative by its owning parent template + composition slot.
|
|
||||||
var ownerCompIds = derivatives.Select(d => d.OwnerCompositionId).Where(id => id.HasValue).Select(id => id!.Value).ToHashSet();
|
|
||||||
var ownerLookup = allTemplates
|
|
||||||
.SelectMany(t => t.Compositions.Select(c => new { Owner = t, Composition = c }))
|
|
||||||
.Where(x => ownerCompIds.Contains(x.Composition.Id))
|
|
||||||
.ToDictionary(x => x.Composition.Id, x => $"'{x.Owner.Name}' (as '{x.Composition.InstanceName}')");
|
|
||||||
|
|
||||||
var details = derivatives
|
|
||||||
.Select(d => d.OwnerCompositionId.HasValue && ownerLookup.TryGetValue(d.OwnerCompositionId.Value, out var label)
|
|
||||||
? label
|
|
||||||
: $"'{d.Name}'");
|
|
||||||
return Result<bool>.Failure(
|
|
||||||
$"Cannot delete template '{template.Name}': it is the base of {derivatives.Count} derived template(s) used in: " +
|
|
||||||
string.Join(", ", details) + ". Remove those compositions first.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for templates composing this template
|
|
||||||
var composedBy = allTemplates
|
|
||||||
.Where(t => t.Compositions.Any(c => c.ComposedTemplateId == templateId))
|
|
||||||
.ToList();
|
|
||||||
if (composedBy.Count > 0)
|
|
||||||
return Result<bool>.Failure(
|
|
||||||
$"Cannot delete template '{template.Name}': it is composed by {composedBy.Count} template(s): " +
|
|
||||||
string.Join(", ", composedBy.Select(c => $"'{c.Name}'")));
|
|
||||||
|
|
||||||
await _repository.DeleteTemplateAsync(templateId, cancellationToken);
|
|
||||||
await _auditService.LogAsync(user, "Delete", "Template", templateId.ToString(), template.Name, null, cancellationToken);
|
await _auditService.LogAsync(user, "Delete", "Template", templateId.ToString(), template.Name, null, cancellationToken);
|
||||||
await _repository.SaveChangesAsync(cancellationToken);
|
await _repository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace ScadaLink.SiteEventLogging.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression tests for SiteEventLogging-006: the schema must index the columns the
|
||||||
|
/// query service filters on so common queries do not full-scan a 1 GB database.
|
||||||
|
/// </summary>
|
||||||
|
public class SchemaIndexTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly SiteEventLogger _logger;
|
||||||
|
private readonly SqliteConnection _verifyConnection;
|
||||||
|
private readonly string _dbPath;
|
||||||
|
|
||||||
|
public SchemaIndexTests()
|
||||||
|
{
|
||||||
|
_dbPath = Path.Combine(Path.GetTempPath(), $"test_index_{Guid.NewGuid()}.db");
|
||||||
|
var options = Options.Create(new SiteEventLogOptions { DatabasePath = _dbPath });
|
||||||
|
_logger = new SiteEventLogger(options, NullLogger<SiteEventLogger>.Instance);
|
||||||
|
|
||||||
|
_verifyConnection = new SqliteConnection($"Data Source={_dbPath}");
|
||||||
|
_verifyConnection.Open();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_verifyConnection.Dispose();
|
||||||
|
_logger.Dispose();
|
||||||
|
if (File.Exists(_dbPath)) File.Delete(_dbPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Schema_HasIndexOnSeverity()
|
||||||
|
{
|
||||||
|
using var cmd = _verifyConnection.CreateCommand();
|
||||||
|
cmd.CommandText =
|
||||||
|
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'site_events'";
|
||||||
|
var indexes = new List<string>();
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read()) indexes.Add(reader.GetString(0));
|
||||||
|
|
||||||
|
Assert.Contains("idx_events_severity", indexes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeverityFilteredQuery_UsesIndex_NotFullScan()
|
||||||
|
{
|
||||||
|
await _logger.LogEventAsync("script", "Error", null, "S", "boom");
|
||||||
|
await _logger.LogEventAsync("script", "Info", null, "S", "ok");
|
||||||
|
|
||||||
|
using var cmd = _verifyConnection.CreateCommand();
|
||||||
|
cmd.CommandText =
|
||||||
|
"EXPLAIN QUERY PLAN SELECT id FROM site_events WHERE severity = 'Error'";
|
||||||
|
var plan = new System.Text.StringBuilder();
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
// The detail column holds the human-readable plan step.
|
||||||
|
plan.Append(reader.GetString(reader.GetOrdinal("detail"))).Append('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
var planText = plan.ToString();
|
||||||
|
Assert.Contains("idx_events_severity", planText);
|
||||||
|
Assert.DoesNotContain("SCAN site_events", planText);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Akka.TestKit.Xunit2;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using ScadaLink.Commons.Messages.Deployment;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
using ScadaLink.Commons.Types.Flattening;
|
||||||
|
using ScadaLink.SiteRuntime.Actors;
|
||||||
|
using ScadaLink.SiteRuntime.Persistence;
|
||||||
|
using ScadaLink.SiteRuntime.Scripts;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace ScadaLink.SiteRuntime.Tests.Actors;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression test for SiteRuntime-015 — <see cref="DeploymentManagerActor"/> must
|
||||||
|
/// reuse a single, injected <see cref="ILoggerFactory"/> for every Instance Actor it
|
||||||
|
/// creates rather than newing (and leaking) a fresh <see cref="LoggerFactory"/> per
|
||||||
|
/// instance.
|
||||||
|
/// </summary>
|
||||||
|
public class DeploymentManagerLoggerFactoryTests : TestKit, IDisposable
|
||||||
|
{
|
||||||
|
private readonly SiteStorageService _storage;
|
||||||
|
private readonly ScriptCompilationService _compilationService;
|
||||||
|
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||||
|
private readonly string _dbFile;
|
||||||
|
|
||||||
|
public DeploymentManagerLoggerFactoryTests()
|
||||||
|
{
|
||||||
|
_dbFile = Path.Combine(Path.GetTempPath(), $"dm-loggerfactory-test-{Guid.NewGuid():N}.db");
|
||||||
|
_storage = new SiteStorageService(
|
||||||
|
$"Data Source={_dbFile}",
|
||||||
|
NullLogger<SiteStorageService>.Instance);
|
||||||
|
_storage.InitializeAsync().GetAwaiter().GetResult();
|
||||||
|
_compilationService = new ScriptCompilationService(
|
||||||
|
NullLogger<ScriptCompilationService>.Instance);
|
||||||
|
_sharedScriptLibrary = new SharedScriptLibrary(
|
||||||
|
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
void IDisposable.Dispose()
|
||||||
|
{
|
||||||
|
Shutdown();
|
||||||
|
try { File.Delete(_dbFile); } catch { /* cleanup */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string MakeConfigJson(string instanceName)
|
||||||
|
{
|
||||||
|
var config = new FlattenedConfiguration
|
||||||
|
{
|
||||||
|
InstanceUniqueName = instanceName,
|
||||||
|
Attributes =
|
||||||
|
[
|
||||||
|
new ResolvedAttribute { CanonicalName = "TestAttr", Value = "1", DataType = "Int32" }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
return JsonSerializer.Serialize(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Counts <see cref="ILoggerFactory.CreateLogger"/> calls and records whether
|
||||||
|
/// the factory was disposed. A passing test proves the single injected factory
|
||||||
|
/// is the one used for every Instance Actor.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class CountingLoggerFactory : ILoggerFactory
|
||||||
|
{
|
||||||
|
public int CreateLoggerCalls;
|
||||||
|
public bool Disposed;
|
||||||
|
|
||||||
|
public ILogger CreateLogger(string categoryName)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref CreateLoggerCalls);
|
||||||
|
return NullLogger.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddProvider(ILoggerProvider provider) { }
|
||||||
|
public void Dispose() => Disposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateInstanceActor_ReusesInjectedLoggerFactory_ForEveryInstance()
|
||||||
|
{
|
||||||
|
// Pre-populate several enabled instances so startup creates multiple
|
||||||
|
// Instance Actors.
|
||||||
|
const int instanceCount = 6;
|
||||||
|
for (int i = 0; i < instanceCount; i++)
|
||||||
|
{
|
||||||
|
var name = $"Inst{i}";
|
||||||
|
await _storage.StoreDeployedConfigAsync(name, MakeConfigJson(name), $"d{i}", $"h{i}", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
var loggerFactory = new CountingLoggerFactory();
|
||||||
|
|
||||||
|
var actor = ActorOf(Props.Create(() => new DeploymentManagerActor(
|
||||||
|
_storage,
|
||||||
|
_compilationService,
|
||||||
|
_sharedScriptLibrary,
|
||||||
|
null,
|
||||||
|
new SiteRuntimeOptions { StartupBatchSize = 100, StartupBatchDelayMs = 5 },
|
||||||
|
NullLogger<DeploymentManagerActor>.Instance,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
loggerFactory)));
|
||||||
|
|
||||||
|
// Allow async startup (load configs + staggered creation).
|
||||||
|
await Task.Delay(2000);
|
||||||
|
|
||||||
|
// Every Instance Actor logger must come from the single injected factory.
|
||||||
|
// Before the fix, each CreateInstanceActor allocated its own LoggerFactory,
|
||||||
|
// so the injected factory would never be touched (CreateLoggerCalls == 0).
|
||||||
|
Assert.Equal(instanceCount, loggerFactory.CreateLoggerCalls);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Akka.TestKit.Xunit2;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
||||||
|
using Microsoft.CodeAnalysis.Scripting;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using ScadaLink.Commons.Messages.ScriptExecution;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
using ScadaLink.Commons.Types.Scripts;
|
||||||
|
using ScadaLink.SiteRuntime.Actors;
|
||||||
|
using ScadaLink.SiteRuntime.Scripts;
|
||||||
|
|
||||||
|
namespace ScadaLink.SiteRuntime.Tests.Actors;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression coverage for SiteRuntime-016 — the short-lived execution actors
|
||||||
|
/// (<see cref="ScriptExecutionActor"/>, <see cref="AlarmExecutionActor"/>) were
|
||||||
|
/// previously untested. Covers success, exception, timeout, Ask-reply, and the
|
||||||
|
/// PoisonPill self-stop after completion.
|
||||||
|
/// </summary>
|
||||||
|
public class ExecutionActorTests : TestKit, IDisposable
|
||||||
|
{
|
||||||
|
private readonly SharedScriptLibrary _sharedLibrary;
|
||||||
|
private readonly ScriptCompilationService _compilationService;
|
||||||
|
|
||||||
|
public ExecutionActorTests()
|
||||||
|
{
|
||||||
|
_compilationService = new ScriptCompilationService(
|
||||||
|
NullLogger<ScriptCompilationService>.Instance);
|
||||||
|
_sharedLibrary = new SharedScriptLibrary(
|
||||||
|
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
void IDisposable.Dispose() => Shutdown();
|
||||||
|
|
||||||
|
private static Script<object?> CompileScript(string code)
|
||||||
|
{
|
||||||
|
var scriptOptions = ScriptOptions.Default
|
||||||
|
.WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly)
|
||||||
|
.WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks");
|
||||||
|
var script = CSharpScript.Create<object?>(code, scriptOptions, typeof(ScriptGlobals));
|
||||||
|
script.Compile();
|
||||||
|
return script;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SiteRuntimeOptions Options(int timeoutSeconds = 30)
|
||||||
|
=> new() { MaxScriptCallDepth = 10, ScriptExecutionTimeoutSeconds = timeoutSeconds };
|
||||||
|
|
||||||
|
// ── ScriptExecutionActor ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ScriptExecutionActor_Success_RepliesWithResultAndStops()
|
||||||
|
{
|
||||||
|
var compiled = CompileScript("return 7 * 6;");
|
||||||
|
var replyTo = CreateTestProbe();
|
||||||
|
var instanceActor = CreateTestProbe();
|
||||||
|
|
||||||
|
var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
|
||||||
|
"Answer", "Inst1", compiled, null, 0,
|
||||||
|
instanceActor.Ref, _sharedLibrary, Options(),
|
||||||
|
replyTo.Ref, "corr-1", NullLogger.Instance,
|
||||||
|
ScriptScope.Root, null, null)));
|
||||||
|
|
||||||
|
Watch(exec);
|
||||||
|
|
||||||
|
var result = replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.Equal("corr-1", result.CorrelationId);
|
||||||
|
Assert.Equal(42, result.ReturnValue);
|
||||||
|
|
||||||
|
// The actor must PoisonPill itself once execution completes.
|
||||||
|
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ScriptExecutionActor_ScriptThrows_RepliesFailureAndStops()
|
||||||
|
{
|
||||||
|
var compiled = CompileScript("throw new InvalidOperationException(\"boom\");");
|
||||||
|
var replyTo = CreateTestProbe();
|
||||||
|
var instanceActor = CreateTestProbe();
|
||||||
|
|
||||||
|
var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
|
||||||
|
"Bad", "Inst1", compiled, null, 0,
|
||||||
|
instanceActor.Ref, _sharedLibrary, Options(),
|
||||||
|
replyTo.Ref, "corr-2", NullLogger.Instance,
|
||||||
|
ScriptScope.Root, null, null)));
|
||||||
|
|
||||||
|
Watch(exec);
|
||||||
|
|
||||||
|
var result = replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Equal("corr-2", result.CorrelationId);
|
||||||
|
Assert.Contains("boom", result.ErrorMessage);
|
||||||
|
|
||||||
|
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ScriptExecutionActor_Timeout_RepliesFailureAndStops()
|
||||||
|
{
|
||||||
|
// A long busy loop that observes the cancellation token so the
|
||||||
|
// 1-second timeout fires cooperatively.
|
||||||
|
var compiled = CompileScript(
|
||||||
|
"while (true) { await System.Threading.Tasks.Task.Delay(50, CancellationToken); }");
|
||||||
|
var replyTo = CreateTestProbe();
|
||||||
|
var instanceActor = CreateTestProbe();
|
||||||
|
|
||||||
|
var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
|
||||||
|
"Slow", "Inst1", compiled, null, 0,
|
||||||
|
instanceActor.Ref, _sharedLibrary, Options(timeoutSeconds: 1),
|
||||||
|
replyTo.Ref, "corr-3", NullLogger.Instance,
|
||||||
|
ScriptScope.Root, null, null)));
|
||||||
|
|
||||||
|
Watch(exec);
|
||||||
|
|
||||||
|
var result = replyTo.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.Contains("timed out", result.ErrorMessage);
|
||||||
|
|
||||||
|
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ScriptExecutionActor_NoReplyTo_StillStopsAfterCompletion()
|
||||||
|
{
|
||||||
|
var compiled = CompileScript("return 1;");
|
||||||
|
var instanceActor = CreateTestProbe();
|
||||||
|
|
||||||
|
// ActorRefs.Nobody as replyTo — fire-and-forget execution.
|
||||||
|
var exec = ActorOf(Props.Create(() => new ScriptExecutionActor(
|
||||||
|
"FireForget", "Inst1", compiled, null, 0,
|
||||||
|
instanceActor.Ref, _sharedLibrary, Options(),
|
||||||
|
ActorRefs.Nobody, "corr-4", NullLogger.Instance,
|
||||||
|
ScriptScope.Root, null, null)));
|
||||||
|
|
||||||
|
Watch(exec);
|
||||||
|
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AlarmExecutionActor ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AlarmExecutionActor_Success_StopsAfterCompletion()
|
||||||
|
{
|
||||||
|
var compiled = CompileScript("return 0;");
|
||||||
|
var instanceActor = CreateTestProbe();
|
||||||
|
|
||||||
|
var exec = ActorOf(Props.Create(() => new AlarmExecutionActor(
|
||||||
|
"HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature",
|
||||||
|
compiled, instanceActor.Ref, _sharedLibrary, Options(),
|
||||||
|
NullLogger.Instance)));
|
||||||
|
|
||||||
|
Watch(exec);
|
||||||
|
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AlarmExecutionActor_ScriptThrows_StillStops()
|
||||||
|
{
|
||||||
|
var compiled = CompileScript("throw new System.Exception(\"alarm-boom\");");
|
||||||
|
var instanceActor = CreateTestProbe();
|
||||||
|
|
||||||
|
var exec = ActorOf(Props.Create(() => new AlarmExecutionActor(
|
||||||
|
"HiTemp", "Inst1", AlarmLevel.High, 5, "High temperature",
|
||||||
|
compiled, instanceActor.Ref, _sharedLibrary, Options(),
|
||||||
|
NullLogger.Instance)));
|
||||||
|
|
||||||
|
Watch(exec);
|
||||||
|
// Even on a throwing on-trigger body, the actor must self-stop.
|
||||||
|
ExpectTerminated(exec, TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -372,6 +372,50 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
|
|||||||
Assert.Contains("Queued", activities);
|
Assert.Contains("Queued", activities);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── StoreAndForward-009: faulting activity subscriber must not corrupt delivery ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EnqueueAsync_ImmediateDeliverySuccess_FaultingActivitySubscriber_StillReportsDelivered()
|
||||||
|
{
|
||||||
|
// StoreAndForward-009: a throwing OnActivity subscriber (e.g. the site event
|
||||||
|
// log) must not be misclassified as a transient delivery failure. Pre-fix the
|
||||||
|
// subscriber's exception escaped RaiseActivity, was caught by EnqueueAsync's
|
||||||
|
// transient-failure handler, and a successfully delivered message was buffered.
|
||||||
|
_service.OnActivity += (_, _, _) => throw new InvalidOperationException("logging blew up");
|
||||||
|
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
|
||||||
|
_ => Task.FromResult(true));
|
||||||
|
|
||||||
|
var result = await _service.EnqueueAsync(
|
||||||
|
StoreAndForwardCategory.ExternalSystem, "api", """{}""");
|
||||||
|
|
||||||
|
Assert.True(result.Accepted);
|
||||||
|
Assert.False(result.WasBuffered); // delivered, NOT buffered
|
||||||
|
|
||||||
|
var msg = await _storage.GetMessageByIdAsync(result.MessageId);
|
||||||
|
Assert.Null(msg); // nothing left in the buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RetryMessageAsync_FaultingActivitySubscriber_DoesNotIncrementRetryCount()
|
||||||
|
{
|
||||||
|
// StoreAndForward-009: a throwing subscriber raised after a successful retry
|
||||||
|
// delivery must not be caught by the retry-failure handler and counted as a
|
||||||
|
// transient failure.
|
||||||
|
var result = await _service.EnqueueAsync(
|
||||||
|
StoreAndForwardCategory.ExternalSystem, "api", """{}""",
|
||||||
|
attemptImmediateDelivery: false, maxRetries: 5);
|
||||||
|
|
||||||
|
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
|
||||||
|
_ => Task.FromResult(true));
|
||||||
|
_service.OnActivity += (_, _, _) => throw new InvalidOperationException("logging blew up");
|
||||||
|
|
||||||
|
await _service.RetryPendingMessagesAsync();
|
||||||
|
|
||||||
|
// The retry succeeded; the message must be gone, not re-buffered with a bumped count.
|
||||||
|
var msg = await _storage.GetMessageByIdAsync(result.MessageId);
|
||||||
|
Assert.Null(msg);
|
||||||
|
}
|
||||||
|
|
||||||
// ── WP-10: Per-source-entity retry settings ──
|
// ── WP-10: Per-source-entity retry settings ──
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -250,6 +250,39 @@ public class StoreAndForwardStorageTests : IAsyncLifetime, IDisposable
|
|||||||
Assert.Equal(2, page2.Count);
|
Assert.Equal(2, page2.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetParkedMessagesAsync_TransactionedReads_CountMatchesFullResultSet()
|
||||||
|
{
|
||||||
|
// StoreAndForward-006: the COUNT(*) and paged SELECT now run inside one
|
||||||
|
// transaction so they share a consistent snapshot. This functional check
|
||||||
|
// guards the fix — it verifies the transaction wiring did not break paging:
|
||||||
|
// the reported TotalCount and the rows assembled across all pages agree, and
|
||||||
|
// a page wide enough to hold every parked row contains exactly TotalCount rows.
|
||||||
|
for (int i = 0; i < 25; i++)
|
||||||
|
{
|
||||||
|
var m = CreateMessage($"txn-{i}", StoreAndForwardCategory.ExternalSystem);
|
||||||
|
m.Status = StoreAndForwardMessageStatus.Parked;
|
||||||
|
await _storage.EnqueueAsync(m);
|
||||||
|
await _storage.UpdateMessageAsync(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
var (wholePage, wholeTotal) = await _storage.GetParkedMessagesAsync(pageNumber: 1, pageSize: 1000);
|
||||||
|
Assert.Equal(25, wholeTotal);
|
||||||
|
Assert.Equal(wholeTotal, wholePage.Count);
|
||||||
|
|
||||||
|
var collected = new List<string>();
|
||||||
|
int reportedTotal = -1;
|
||||||
|
for (int page = 1; ; page++)
|
||||||
|
{
|
||||||
|
var (rows, total) = await _storage.GetParkedMessagesAsync(pageNumber: page, pageSize: 7);
|
||||||
|
reportedTotal = total;
|
||||||
|
collected.AddRange(rows.Select(r => r.Id));
|
||||||
|
if (rows.Count < 7) break;
|
||||||
|
}
|
||||||
|
Assert.Equal(reportedTotal, collected.Count);
|
||||||
|
Assert.Equal(25, collected.Distinct().Count());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetMessageCountByStatusAsync_ReturnsAccurateCount()
|
public async Task GetMessageCountByStatusAsync_ReturnsAccurateCount()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -153,4 +153,77 @@ public class CycleDetectorTests
|
|||||||
|
|
||||||
Assert.Null(result);
|
Assert.Null(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// TemplateEngine-013: robustness against duplicate Ids and Id 0
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DetectInheritanceCycle_DuplicateIdsInList_DoesNotThrow()
|
||||||
|
{
|
||||||
|
// Two not-yet-saved templates both carry Id == 0. ToDictionary(t => t.Id)
|
||||||
|
// would throw ArgumentException; the detector must tolerate it.
|
||||||
|
var templateA = new Template("A") { Id = 0 };
|
||||||
|
var templateB = new Template("B") { Id = 0 };
|
||||||
|
var saved = new Template("Saved") { Id = 1 };
|
||||||
|
var all = new List<Template> { templateA, templateB, saved };
|
||||||
|
|
||||||
|
var ex = Record.Exception(() => CycleDetector.DetectInheritanceCycle(1, 0, all));
|
||||||
|
|
||||||
|
Assert.Null(ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DetectCompositionCycle_DuplicateIdsInList_DoesNotThrow()
|
||||||
|
{
|
||||||
|
var templateA = new Template("A") { Id = 0 };
|
||||||
|
var templateB = new Template("B") { Id = 0 };
|
||||||
|
var all = new List<Template> { templateA, templateB };
|
||||||
|
|
||||||
|
var ex = Record.Exception(() => CycleDetector.DetectCompositionCycle(1, 2, all));
|
||||||
|
|
||||||
|
Assert.Null(ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DetectCrossGraphCycle_DuplicateIdsInList_DoesNotThrow()
|
||||||
|
{
|
||||||
|
var templateA = new Template("A") { Id = 0 };
|
||||||
|
var templateB = new Template("B") { Id = 0 };
|
||||||
|
var all = new List<Template> { templateA, templateB };
|
||||||
|
|
||||||
|
var ex = Record.Exception(() => CycleDetector.DetectCrossGraphCycle(5, 1, 2, all));
|
||||||
|
|
||||||
|
Assert.Null(ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DetectInheritanceCycle_RealIdZero_StillDetectsCycle()
|
||||||
|
{
|
||||||
|
// A template legitimately stored with Id 0 (in-memory / test scenario):
|
||||||
|
// a self-inheritance attempt must still be detected, not skipped as
|
||||||
|
// "no parent" by a 0-as-sentinel overload.
|
||||||
|
var template = new Template("Zero") { Id = 0 };
|
||||||
|
var all = new List<Template> { template };
|
||||||
|
|
||||||
|
var result = CycleDetector.DetectInheritanceCycle(0, 0, all);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Contains("itself", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DetectInheritanceCycle_ParentChainThroughIdZero_DetectsCycle()
|
||||||
|
{
|
||||||
|
// Child(1) -> parent Zero(0) -> parent Child(1): a cycle running through
|
||||||
|
// a template whose real Id is 0 must be detected, not silently skipped.
|
||||||
|
var zero = new Template("Zero") { Id = 0, ParentTemplateId = 1 };
|
||||||
|
var child = new Template("Child") { Id = 1, ParentTemplateId = null };
|
||||||
|
var all = new List<Template> { zero, child };
|
||||||
|
|
||||||
|
var result = CycleDetector.DetectInheritanceCycle(1, 0, all);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Contains("cycle", result, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,38 @@ public class RevisionHashServiceTests
|
|||||||
Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2));
|
Assert.Equal(_sut.ComputeHash(config1), _sut.ComputeHash(config2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HashableRecords_PropertiesDeclaredAlphabetically()
|
||||||
|
{
|
||||||
|
// TemplateEngine-011: revision-hash determinism depends entirely on the
|
||||||
|
// private Hashable* records declaring their properties in alphabetical
|
||||||
|
// order (System.Text.Json emits properties in CLR declaration order and
|
||||||
|
// does not sort). This guards against a contributor silently changing
|
||||||
|
// every revision hash by adding a property out of order.
|
||||||
|
var nested = typeof(RevisionHashService)
|
||||||
|
.GetNestedTypes(System.Reflection.BindingFlags.NonPublic)
|
||||||
|
.Where(t => t.Name.StartsWith("Hashable"))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Assert.NotEmpty(nested);
|
||||||
|
|
||||||
|
foreach (var type in nested)
|
||||||
|
{
|
||||||
|
var propNames = type
|
||||||
|
.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
|
||||||
|
.Where(p => p.Name != "EqualityContract")
|
||||||
|
.Select(p => p.Name)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var sorted = propNames.OrderBy(n => n, StringComparer.Ordinal).ToList();
|
||||||
|
|
||||||
|
Assert.True(
|
||||||
|
propNames.SequenceEqual(sorted),
|
||||||
|
$"{type.Name} properties must be declared alphabetically. " +
|
||||||
|
$"Declared: [{string.Join(", ", propNames)}] Expected: [{string.Join(", ", sorted)}]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static FlattenedConfiguration CreateConfig(string instanceName, string tempValue)
|
private static FlattenedConfiguration CreateConfig(string instanceName, string tempValue)
|
||||||
{
|
{
|
||||||
return new FlattenedConfiguration
|
return new FlattenedConfiguration
|
||||||
|
|||||||
@@ -122,11 +122,13 @@ public class TemplateServiceTests
|
|||||||
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||||
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Instance> { new Instance("Pump1") { Id = 1, TemplateId = 1, SiteId = 1 } });
|
.ReturnsAsync(new List<Instance> { new Instance("Pump1") { Id = 1, TemplateId = 1, SiteId = 1 } });
|
||||||
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Template> { template });
|
||||||
|
|
||||||
var result = await _service.DeleteTemplateAsync(1, "admin");
|
var result = await _service.DeleteTemplateAsync(1, "admin");
|
||||||
|
|
||||||
Assert.True(result.IsFailure);
|
Assert.True(result.IsFailure);
|
||||||
Assert.Contains("referenced by", result.Error);
|
Assert.Contains("instance(s) reference it", result.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -143,7 +145,7 @@ public class TemplateServiceTests
|
|||||||
var result = await _service.DeleteTemplateAsync(1, "admin");
|
var result = await _service.DeleteTemplateAsync(1, "admin");
|
||||||
|
|
||||||
Assert.True(result.IsFailure);
|
Assert.True(result.IsFailure);
|
||||||
Assert.Contains("inherited by", result.Error);
|
Assert.Contains("child template(s) inherit from it", result.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -162,7 +164,36 @@ public class TemplateServiceTests
|
|||||||
var result = await _service.DeleteTemplateAsync(1, "admin");
|
var result = await _service.DeleteTemplateAsync(1, "admin");
|
||||||
|
|
||||||
Assert.True(result.IsFailure);
|
Assert.True(result.IsFailure);
|
||||||
Assert.Contains("composed by", result.Error);
|
Assert.Contains("template(s) compose it", result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteTemplate_MultipleConstraints_ReportsAllNotJustFirst()
|
||||||
|
{
|
||||||
|
// TemplateEngine-014: DeleteTemplateAsync delegates its constraint check
|
||||||
|
// to the single TemplateDeletionService implementation, which accumulates
|
||||||
|
// every blocking reason instead of returning on the first failing category.
|
||||||
|
var template = new Template("Busy") { Id = 1 };
|
||||||
|
var composer = new Template("Composer") { Id = 3 };
|
||||||
|
composer.Compositions.Add(new TemplateComposition("Module") { Id = 1, TemplateId = 3, ComposedTemplateId = 1 });
|
||||||
|
|
||||||
|
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||||
|
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Instance> { new Instance("Inst1") { Id = 1, TemplateId = 1, SiteId = 1 } });
|
||||||
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Template>
|
||||||
|
{
|
||||||
|
template,
|
||||||
|
new Template("Child") { Id = 2, ParentTemplateId = 1 },
|
||||||
|
composer
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = await _service.DeleteTemplateAsync(1, "admin");
|
||||||
|
|
||||||
|
Assert.True(result.IsFailure);
|
||||||
|
Assert.Contains("instance(s) reference it", result.Error);
|
||||||
|
Assert.Contains("child template(s) inherit from it", result.Error);
|
||||||
|
Assert.Contains("template(s) compose it", result.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user