perf: close Theme 6 — 11 allocation / N+1 / lock-contention findings

Well-localised perf fixes across 8 modules.

Lock decoupling / SQL streaming:
- AuditLog-005: SqliteAuditWriter gains dedicated read-only _readConnection
  (+ _readLock) backed by WAL journal mode. GetBacklogStatsAsync,
  ReadPendingAsync, ReadPendingSinceAsync, ReadForwardedAsync no longer
  contend with the hot-path INSERT lock — backlog probes on a 30s timer
  can't stall the writer under multi-hundred-K Pending backlog.
- SEL-022: dropped Cache=Shared from SiteEventLogger's default connection
  string (single-connection logger; mode was dormant config).

Memory / streaming:
- CLI-019: bundle export streams base64 in 1 MB-aligned chunks via
  Convert.TryFromBase64Chars straight into the FileStream — no more
  full-bundle byte[] allocation.
- CentralUI-031: TransportImport now stages the upload to a per-session
  temp file under Path.GetTempPath() (replaces in-memory byte[] field);
  page implements IDisposable to delete the temp file on reset / new
  upload / dispose. Per-circuit working set drops from ~100 MB to ~80 KB.

N+1 hoisting:
- Transport-008: added ITemplateEngineRepository.GetTemplatesWithChildrenAsync
  bulk method; BundleImporter.PreviewAsync calls it once instead of per-
  template-name. Single query with .Include(...).AsSplitQuery().
- DM-023: BuildDeployArtifactsCommandAsync's per-site loop now references
  a pre-fetched GlobalArtifactSnapshot (shared scripts, external systems,
  DB connections, notification lists, SMTP) instead of re-querying per site.
- MgmtSvc-023: HandleQueryDeployments unfiltered branch uses one
  GetAllInstancesAsync bulk load + Dictionary<int,int?> lookup (was a
  GetInstanceByIdAsync per record).

Small allocations / per-tick rebuilds:
- InboundAPI-019: AuditWriteMiddleware gates EnableBuffering() on
  RequestHasBody() so GET/HEAD/DELETE/TRACE/OPTIONS and Content-Length:0
  requests skip the FileBufferingReadStream allocation.
- NotifOutbox-006: ResolveAdapters dictionary now cached on
  _adaptersCache (built lazily on first sweep) + actor-lifetime
  _adaptersScope; ResolveAdapters no longer rebuilds per dispatch tick.

Verify-only:
- Comm-017: Confirmed _inProgressDeployments was deleted by Comm-016 in
  commit ac96b83 — marked Resolved with that attribution. No code change.

Doc-correction:
- NS-022: Updated MailKitSmtpClientWrapper XML doc to spell out single-
  connection / per-delivery-factory contract (option (b) — transient
  client per Send — rejected because it re-handshakes TLS per email).

10+ new regression tests across 8 test projects. Build clean; affected
suites all green. README regenerated: 54 open (was 65).
This commit is contained in:
Joseph Doherty
2026-05-28 07:47:24 -04:00
parent 2ed5c6c379
commit 55f46e7c92
34 changed files with 1131 additions and 149 deletions
+15 -4
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 3 |
| Open findings | 2 |
## Summary
@@ -263,7 +263,7 @@ tests already exercise the success path).
|--|--|
| Severity | Medium |
| Category | Performance & resource management |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs:597-657` |
**Description**
@@ -293,9 +293,20 @@ lazily on a dedicated background tick so the reporter reads a pre-computed snaps
without acquiring the write lock. Option (a) also unblocks `ReadPendingAsync` /
`ReadPendingSinceAsync` from competing with the writer.
**Resolution**
**Resolution (2026-05-28):**
_Unresolved._
Took option (a). `SqliteAuditWriter` now opens a second `SqliteConnection`
(`_readConnection`) on the same file in the ctor, after `InitializeSchema`
sets `PRAGMA journal_mode = WAL` on the writer connection — WAL lets a
second connection read concurrently with the active writer without taking
`_writeLock`. The read connection is guarded by its own `_readLock` (since
`SqliteConnection` itself is not thread-safe across callers) and used by
`GetBacklogStatsAsync`, `ReadPendingAsync`, `ReadPendingSinceAsync`, and
`ReadForwardedAsync`. `DisposeAsync` disposes it after the writer drains.
Regression test `SqliteAuditWriterBacklogStatsTests.GetBacklogStatsAsync_DoesNotBlockOnConcurrentWriteLoad`
saturates the writer with a 2 000-row burst and asserts the probe returns
in under 1 s — would fail against the pre-fix code (the probe queued
behind every batch INSERT under `_writeLock`).
### AuditLog-006 — `SqliteAuditWriter.Dispose()` does sync-over-async and may deadlock
+4 -2
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 4 |
| Open findings | 3 |
## Summary
@@ -888,9 +888,11 @@ and pass after.
|--|--|
| Severity | Medium |
| Category | Performance & resource management |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.CLI/Commands/BundleCommands.cs:117-124`, `src/ScadaLink.CLI/ManagementHttpClient.cs:47-92` |
**Resolution (2026-05-28):** Replaced the `Convert.FromBase64String(...)` + `File.WriteAllBytes(...)` pair with a new `StreamBase64ToFile(base64, output)` helper that slices the base64 string into 4-char-aligned chunks (1 MB by default) and decodes each chunk straight into a `FileStream` via `Convert.TryFromBase64Chars`. The intermediate `byte[]` and the synchronous full-bundle write are gone — peak working set drops from ~base64-string + ~byte[] + ~envelope-string to ~base64-string + small-chunk-buffer + ~envelope-string. The response body is still buffered into the management envelope string (the `POST /management` wire format does not currently support streaming responses — this finding is bounded by that limit per the recommendation's stop-gap), so a streaming `POST /api/bundle/export` REST endpoint remains a follow-up. Regression tests `BundleCommandsStreamingTests` (6 tests) cover small payload round-trip, multi-chunk boundaries, empty input, invalid base64 → `FormatException`, and argument validation.
**Description**
`Component-Transport.md:271` ceilings the raw bundle at 100 MB and notes the
+4 -2
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 4 |
| Open findings | 3 |
## Summary
@@ -1498,9 +1498,11 @@ the expected line count regardless of thread interleaving.
|--|--|
| Severity | Low |
| Category | Performance & resource management |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.CentralUI/Components/Pages/Design/TransportImport.razor.cs:72,104-142,160-161` |
**Resolution (2026-05-28):** Replaced the `private byte[]? _bundleBytes` field with `private string? _bundleTempPath`. `OnFileSelectedAsync` now creates `Path.GetTempPath()/scadalink-transport-staging/` (created on first use) and streams the upload via `InputFile.OpenReadStream(maxBytes).CopyToAsync(FileStream)` straight to a `Guid.NewGuid():N + .scadabundle` temp file; `TryLoadAsync` opens the same path as a fresh `FileStream` for each `IBundleImporter.LoadAsync` call. The component now implements `IDisposable` and a `DeleteBundleTempFile()` helper that runs on `ResetSessionState`, `OnFileSelectedAsync` (before a new upload), and `Dispose` (circuit teardown); IO failures during cleanup are swallowed so audit-failure-style defensive semantics hold. Per-circuit working set drops from up to `MaxBundleSizeMb` (default 100 MB) per open wizard to the 80 KB FileStream buffer. The existing reflection-based test helper `SeedAtPassphraseStep` was migrated to write bytes to a real temp file and set `_bundleTempPath`, so the 7 existing TransportImport bUnit tests still pass against the new staging model.
**Description**
`OnFileSelectedAsync` reads the uploaded `.scadabundle` into a `MemoryStream`,
+10 -2
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 4 |
| Open findings | 3 |
## Summary
@@ -857,9 +857,17 @@ Either way, replace `CentralCommunicationActorTests.ConnectionLost_DebugStreamsK
|--|--|
| Severity | Medium |
| Category | Performance & resource management |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.Communication/Actors/CentralCommunicationActor.cs:73`, `src/ScadaLink.Communication/Actors/CentralCommunicationActor.cs:501`, `src/ScadaLink.Communication/Actors/CentralCommunicationActor.cs:357-367` |
**Resolution (2026-05-28):** Closed by Comm-016 — field removed in commit ac96b83.
The `_inProgressDeployments` dictionary, the `TrackMessageForCleanup` helper,
and the `HandleConnectionStateChanged` handler that consumed them were all
deleted as part of Comm-016's dead-code purge. A grep for `_inProgressDeployments`
in `CentralCommunicationActor.cs` finds only the explanatory comment block at
line 63-74 that documents the removal. There is no longer any unbounded-growth
hazard — the field does not exist.
**Description**
`TrackMessageForCleanup` inserts `_inProgressDeployments[deploy.DeploymentId] =
+4 -2
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 4 |
| Open findings | 3 |
## Summary
@@ -1157,9 +1157,11 @@ Either:
|--|--|
| Severity | Low |
| Category | Performance & resource management |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.DeploymentManager/ArtifactDeploymentService.cs:82-144,169-173` |
**Resolution (2026-05-28):** Hoisted the global artifact queries (shared scripts, external systems + methods, DB connections, notification lists, SMTP configurations) out of the per-site loop into a new private `FetchGlobalArtifactsAsync` that produces a `GlobalArtifactSnapshot` record. `DeployToAllSitesAsync` now calls it ONCE before the loop and threads the snapshot through a new prefetched-globals overload of `BuildDeployArtifactsCommandAsync`; the public single-site overload keeps the prior fetch-then-build behaviour for `RetryForSiteAsync`. Only the per-site data-connection query remains inside the loop. Regression tests `DeployToAllSitesAsync_HoistsGlobalArtifactQueriesOutOfPerSiteLoop` (three sites; pins exactly-one call to each global getter and one per-site call to `GetDataConnectionsBySiteIdAsync`) and `RetryForSiteAsync_SingleSitePath_StillRunsTheGlobalQueriesOnce` (single-site path still owns its own fetch).
**Description**
`DeployToAllSitesAsync` loops over sites and calls
+4 -2
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 3 |
| Open findings | 2 |
## Summary
@@ -969,7 +969,9 @@ synchronous throw) to pin the new contract.
| Severity | Low |
| Category | Performance & resource management |
| Location | `src/ScadaLink.InboundAPI/Middleware/AuditWriteMiddleware.cs:141` |
| Status | Open |
| Status | Resolved |
**Resolution (2026-05-28):** Added a `RequestHasBody(HttpRequest)` guard that returns `true` only when `ContentLength > 0` or the method is POST / PUT / PATCH (the `HttpMethods.IsPost/Put/Patch` helpers); the `EnableBuffering` + `ReadBufferedRequestBodyAsync` call now sits behind that guard. Bodyless GET / HEAD / DELETE / TRACE / OPTIONS requests (and any explicit `Content-Length: 0`) skip the `FileBufferingReadStream` allocation; body-carrying methods with no Content-Length (chunked POST etc.) still buffer. Regression tests `BodylessMethod_SkipsEnableBuffering_RequestStreamIsNotReplaced` (GET/HEAD/DELETE theory), `BodylessPost_ContentLengthZero_SkipsEnableBuffering`, and `PostWithBody_StillEnablesBuffering_AndCapturesRequestSummary` (anti-regression).
**Description**
+4 -2
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 4 (1 Deferred — see ManagementService-012) |
| Open findings | 3 (1 Deferred — see ManagementService-012) |
## Summary
@@ -1081,9 +1081,11 @@ Update `Component-ManagementService.md`:
|--|--|
| Severity | Low |
| Category | Performance & resource management |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.ManagementService/ManagementActor.cs:1276``:1295` |
**Resolution (2026-05-28):** Swapped the per-`InstanceId` `GetInstanceByIdAsync` lookup loop for a single `templateRepo.GetAllInstancesAsync()` bulk fetch, projected into a `Dictionary<int, int?>` keyed by `Instance.Id`. The unfiltered branch now hits the configuration database exactly twice (deployment records + instances) regardless of fleet size. Existing test `QueryDeployments_UnfilteredForSiteScopedUser_DropsOutOfScopeRecords` was updated to mock the bulk path and to assert `GetInstanceByIdAsync` is no longer used on the unfiltered branch; new regression test `QueryDeployments_UnfilteredForSiteScopedUser_UsesBulkInstanceLoad_NotPerRecordLookup` pins `GetAllInstancesAsync` is invoked exactly once even with duplicate-instance records.
**Description**
The site-scoped unfiltered branch of `HandleQueryDeployments` (added under
+3 -5
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 9 |
| Open findings | 8 |
## Summary
@@ -279,7 +279,7 @@ remains the CD-015 raw-SQL `IF NOT EXISTS … INSERT` with `2601/2627` catch in
|--|--|
| Severity | Low |
| Category | Performance & resource management |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationOutbox/NotificationOutboxActor.cs:267-277` |
**Description**
@@ -310,9 +310,7 @@ sweeps") in the actor XML, or — preferred — cache only the *types* at startu
(`PreStart`) and resolve the scoped instance per sweep, so future adapters with
stateful intent (timeouts, circuit breakers) cannot accidentally lose state.
**Resolution**
_Unresolved._
**Resolution (2026-05-28):** Cached the `NotificationType → adapter` dictionary on a new actor field `_adaptersCache`, built lazily on the first dispatch sweep that needs it. The cache is paired with an actor-lifetime `IServiceScope` (`_adaptersScope`, created on first use and disposed in `PostStop`) so the resolved scoped adapter instances live as long as the cache itself — respecting `EmailNotificationDeliveryAdapter`'s scoped lifetime without leaking captive-DbContext references across the actor's full lifetime. `RunDispatchPass` now calls `ResolveAdapters()` (instance method, no args), and the existing "resolved from the per-sweep scope" comment was rewritten to cross-reference the new cache rationale. Adapter set is static per process lifetime (confirmed against the DI registration in `AddNotificationOutbox`), so no invalidation hook is needed. Regression test `Dispatch_ResolvesAdaptersOnce_AcrossMultipleSweeps` registers a counting factory and pins the resolution count at exactly 1 across three end-to-end dispatch sweeps.
### NotificationOutbox-007 — `NotificationOutboxOptions.DispatchBatchSize`, `DeliveredKpiWindow`, and `PurgeInterval` are not in the design document
+4 -2
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 6 |
| Open findings | 5 |
## Summary
@@ -735,9 +735,11 @@ Pass the sender mailbox into the wrapper's `AuthenticateAsync` path. The cleanes
|--|--|
| Severity | Low |
| Category | Performance & resource management |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.NotificationService/MailKitSmtpClientWrapper.cs:14`, `src/ScadaLink.NotificationService/ServiceCollectionExtensions.cs:19` |
**Resolution (2026-05-28):** Took option (a) — updated the class-level XML doc on `MailKitSmtpClientWrapper` with an explicit lifetime section (NS-022) describing single-connection ownership and the per-delivery factory contract (NOT a pool; `MailKit.Net.Smtp.SmtpClient` holds one TCP/TLS connection and is not thread-safe; the DI `Func<ISmtpClientWrapper>` is a factory, callers run connect/auth/send/disconnect/dispose per send). A field-level comment was added to the `_client` declaration cross-referencing the class docs so a maintainer hunting from the field-initializer immediately sees the constraint. The wrapper code itself is unchanged — option (b) (transient SmtpClient per Send) was deliberately not taken because it would re-handshake TLS per email which is materially more expensive than the current per-delivery factory model the callers already implement. The concurrent-connection limit regression on the central-side delivery path (per-site semaphore on `EmailNotificationDeliveryAdapter`) is out of NotificationService scope and tracked separately.
**Description**
`MailKitSmtpClientWrapper` declares `private readonly SmtpClient _client = new();` — a single `SmtpClient` is constructed when the wrapper is constructed and lives for the wrapper's lifetime. The DI registration is `services.AddSingleton<Func<ISmtpClientWrapper>>(_ => () => new MailKitSmtpClientWrapper());` (`ServiceCollectionExtensions.cs:19`) — every invocation of the factory creates a **new** wrapper and therefore a **new** `SmtpClient`. `NotificationDeliveryService.DeliverAsync` (the orphan, per NS-019) and `EmailNotificationDeliveryAdapter.SendAsync` both invoke the factory per send and dispose the wrapper at end of send. So in practice there is no connection pooling — every send pays a full TCP+TLS handshake.
+16 -27
View File
@@ -41,37 +41,37 @@ module file and counted in **Total**.
|----------|---------------|
| Critical | 0 |
| High | 0 |
| Medium | 22 |
| Low | 43 |
| **Total** | **65** |
| Medium | 19 |
| Low | 35 |
| **Total** | **54** |
## Module Status
| Module | Last reviewed | Commit | Open (C/H/M/L) | Open | Total |
|--------|---------------|--------|----------------|------|-------|
| [AuditLog](AuditLog/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/1 | 3 | 11 |
| [CLI](CLI/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/2 | 3 | 23 |
| [CentralUI](CentralUI/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/4 | 4 | 33 |
| [AuditLog](AuditLog/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/1 | 2 | 11 |
| [CLI](CLI/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/2 | 2 | 23 |
| [CentralUI](CentralUI/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/3 | 3 | 33 |
| [ClusterInfrastructure](ClusterInfrastructure/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/3 | 3 | 14 |
| [Commons](Commons/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/4 | 4 | 23 |
| [Communication](Communication/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/1 | 2 | 22 |
| [Communication](Communication/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/1 | 1 | 22 |
| [ConfigurationDatabase](ConfigurationDatabase/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/2 | 3 | 24 |
| [DataConnectionLayer](DataConnectionLayer/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/0 | 0 | 22 |
| [DeploymentManager](DeploymentManager/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/4 | 4 | 24 |
| [DeploymentManager](DeploymentManager/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/3 | 3 | 24 |
| [ExternalSystemGateway](ExternalSystemGateway/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/1 | 2 | 23 |
| [HealthMonitoring](HealthMonitoring/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/2 | 2 | 23 |
| [Host](Host/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/3 | 4 | 22 |
| [InboundAPI](InboundAPI/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/2 | 3 | 25 |
| [ManagementService](ManagementService/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/1 | 3 | 23 |
| [NotificationOutbox](NotificationOutbox/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/2 | 2 | 10 |
| [NotificationService](NotificationService/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/2 | 3 | 25 |
| [InboundAPI](InboundAPI/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/1 | 2 | 25 |
| [ManagementService](ManagementService/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/0 | 2 | 23 |
| [NotificationOutbox](NotificationOutbox/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/1 | 1 | 10 |
| [NotificationService](NotificationService/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/1 | 2 | 25 |
| [Security](Security/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/1 | 1 | 21 |
| [SiteCallAudit](SiteCallAudit/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/1 | 3 | 6 |
| [SiteEventLogging](SiteEventLogging/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/3 | 3 | 23 |
| [SiteEventLogging](SiteEventLogging/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/0/2 | 2 | 23 |
| [SiteRuntime](SiteRuntime/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/2/0 | 2 | 26 |
| [StoreAndForward](StoreAndForward/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/3/2 | 5 | 24 |
| [TemplateEngine](TemplateEngine/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/3/0 | 3 | 22 |
| [Transport](Transport/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/2 | 3 | 12 |
| [Transport](Transport/findings.md) | 2026-05-28 | `1eb6e97` | 0/0/1/1 | 2 | 12 |
## Pending Findings
@@ -88,14 +88,11 @@ _None open._
_None open._
### Medium (22)
### Medium (19)
| ID | Module | Title |
|----|--------|-------|
| AuditLog-001 | [AuditLog](AuditLog/findings.md) | Combined-telemetry transport is plumbed end-to-end but never invoked in production |
| AuditLog-005 | [AuditLog](AuditLog/findings.md) | `GetBacklogStatsAsync` holds the SQLite hot-path write lock for the full COUNT+MIN scan |
| CLI-019 | [CLI](CLI/findings.md) | `bundle export` decodes the entire base64 bundle into memory before writing |
| Communication-017 | [Communication](Communication/findings.md) | `_inProgressDeployments` grows unboundedly — successful deployments are never cleaned up |
| ConfigurationDatabase-016 | [ConfigurationDatabase](ConfigurationDatabase/findings.md) | `InboundApiRepository.GetApiKeyByValueAsync` hashes the candidate with the unpeppered `ApiKeyHasher.Default` |
| ExternalSystemGateway-020 | [ExternalSystemGateway](ExternalSystemGateway/findings.md) | `JsonElementToParameterValue` silently downcasts non-Int64 JSON numbers to `double`, losing precision for `decimal` SQL parameters on retry |
| Host-016 | [Host](Host/findings.md) | Site `CentralContactPoints` second entry targets the site's own remoting port |
@@ -115,7 +112,7 @@ _None open._
| TemplateEngine-020 | [TemplateEngine](TemplateEngine/findings.md) | `Create*` audit entries are written with `EntityId = "0"` before `SaveChangesAsync` populates the real key |
| Transport-010 | [Transport](Transport/findings.md) | Critical Overwrite + cross-cutting paths uncovered by tests |
### Low (43)
### Low (35)
| ID | Module | Title |
|----|--------|-------|
@@ -123,7 +120,6 @@ _None open._
| CLI-020 | [CLI](CLI/findings.md) | `bundle export` success-envelope parse is unguarded |
| CLI-022 | [CLI](CLI/findings.md) | `CommandTreeTests` excludes the two new command groups |
| CentralUI-029 | [CentralUI](CentralUI/findings.md) | `ConfigurationAuditLog` uses `JS.InvokeAsync<int>("eval", ...)` instead of a dedicated JS module |
| CentralUI-031 | [CentralUI](CentralUI/findings.md) | `TransportImport` buffers the full bundle bytes in component state |
| CentralUI-032 | [CentralUI](CentralUI/findings.md) | `AuditResultsGrid` paging is forward-only, no Previous button |
| CentralUI-033 | [CentralUI](CentralUI/findings.md) | Drill-in / query-string code paths for the new Transport + SiteCalls pages are untested |
| ClusterInfrastructure-011 | [ClusterInfrastructure](ClusterInfrastructure/findings.md) | `SectionName` constant is decorative — no binding site references it |
@@ -138,7 +134,6 @@ _None open._
| ConfigurationDatabase-024 | [ConfigurationDatabase](ConfigurationDatabase/findings.md) | Missing test coverage for SPLIT-RANGE failure-continuation and production-shape rowversion delete |
| DeploymentManager-021 | [DeploymentManager](DeploymentManager/findings.md) | `ResolveSiteIdentifierAsync` silently substitutes the DB id when the site row is missing |
| DeploymentManager-022 | [DeploymentManager](DeploymentManager/findings.md) | `Pending` and `InProgress` are written back-to-back with no intervening work |
| DeploymentManager-023 | [DeploymentManager](DeploymentManager/findings.md) | `BuildDeployArtifactsCommandAsync` re-queries system-wide artifacts once per site |
| DeploymentManager-024 | [DeploymentManager](DeploymentManager/findings.md) | Test probe actors hold mutable static state across tests |
| ExternalSystemGateway-021 | [ExternalSystemGateway](ExternalSystemGateway/findings.md) | `ApplyAuth` silently sends an unauthenticated request on unknown `AuthType`, empty `AuthConfiguration`, or malformed Basic config |
| HealthMonitoring-021 | [HealthMonitoring](HealthMonitoring/findings.md) | `CentralSiteId = "central"` reserved constant silently collides with a real site named "central" |
@@ -146,19 +141,13 @@ _None open._
| Host-018 | [Host](Host/findings.md) | Shipped per-role configs omit `NodeOptions.NodeName`, leaving `SourceNode` null |
| Host-020 | [Host](Host/findings.md) | `MinimumLevel.Is` silently overrides any operator-set `Serilog:MinimumLevel` |
| Host-021 | [Host](Host/findings.md) | Microsoft `Logging:LogLevel` section in `appsettings.json` is dead config under Serilog |
| InboundAPI-019 | [InboundAPI](InboundAPI/findings.md) | `EnableBuffering()` called unconditionally on every request, including bodyless requests |
| InboundAPI-023 | [InboundAPI](InboundAPI/findings.md) | `EndpointExtensions.HandleInboundApiRequest` composition wiring has no test coverage |
| ManagementService-023 | [ManagementService](ManagementService/findings.md) | HandleQueryDeployments unfiltered branch is N+1 on instance lookup |
| NotificationOutbox-006 | [NotificationOutbox](NotificationOutbox/findings.md) | `ResolveAdapters` rebuilds the `NotificationType → adapter` dictionary on every dispatch sweep |
| NotificationOutbox-008 | [NotificationOutbox](NotificationOutbox/findings.md) | `FallbackMaxRetries` / `FallbackRetryDelay` path is unreachable in production AND untested |
| NotificationService-022 | [NotificationService](NotificationService/findings.md) | `MailKitSmtpClientWrapper` holds a long-lived `SmtpClient`; combined with per-send factory, the design comment about pooling is contradicted |
| NotificationService-025 | [NotificationService](NotificationService/findings.md) | `CredentialRedactor` over-masks: any 4-character credential component is masked anywhere it appears, including unrelated log text |
| Security-021 | [Security](Security/findings.md) | `RequireHttpsCookie=false` dev opt-out has no warning path — an HTTP production deployment silently transmits the JWT bearer credential in cleartext |
| SiteCallAudit-006 | [SiteCallAudit](SiteCallAudit/findings.md) | Stuck-only paging test does not exercise the multi-page boundary with an interleaved non-stuck row at the cursor |
| SiteEventLogging-018 | [SiteEventLogging](SiteEventLogging/findings.md) | `FailedWriteCount` is exposed but never consumed by Health Monitoring |
| SiteEventLogging-022 | [SiteEventLogging](SiteEventLogging/findings.md) | `Cache=Shared` is redundant for a single-connection logger |
| SiteEventLogging-023 | [SiteEventLogging](SiteEventLogging/findings.md) | Concurrent-stress test uses a non-volatile `stop` flag |
| StoreAndForward-022 | [StoreAndForward](StoreAndForward/findings.md) | `NotifyCachedCallObserverAsync` silently drops the entire audit lifecycle when the message id is not a parseable `TrackedOperationId` |
| StoreAndForward-023 | [StoreAndForward](StoreAndForward/findings.md) | `siteId` silently defaults to empty when no `IStoreAndForwardSiteContext` is registered, degrading audit telemetry correlation |
| Transport-008 | [Transport](Transport/findings.md) | `PreviewAsync` issues an N+1 `GetTemplateWithChildrenAsync` per matching template name |
| Transport-012 | [Transport](Transport/findings.md) | "Bundle Import" filter promised in design doc not surfaced in Configuration Audit Log Viewer UI |
+10 -2
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 5 |
| Open findings | 4 |
## Summary
@@ -1074,9 +1074,17 @@ avoid all string-parsing.
|--|--|
| Severity | Low |
| Category | Code organization & conventions |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.SiteEventLogging/SiteEventLogger.cs:52` |
**Resolution (2026-05-28):** Dropped `Cache=Shared` from the default connection
string in `SiteEventLogger`'s ctor — the logger owns exactly one
`SqliteConnection` serialised under `_writeLock`, so the cross-connection
shared-cache mode is dormant. The `connectionStringOverride` ctor parameter
still lets a test path inject `Cache=Shared` explicitly when needed. No test
relied on the default option (grep across `tests/ScadaLink.SiteEventLogging.Tests`
finds zero references). Behaviour unchanged for production callers.
**Description**
The connection string is built as
+4 -2
View File
@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 9 |
| Open findings | 8 |
## Summary
@@ -312,9 +312,11 @@ _Unresolved._
|--|--|
| Severity | Low |
| Category | Performance & resource management |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.Transport/Import/BundleImporter.cs:252-272` |
**Resolution (2026-05-28):** Added a bulk `GetTemplatesWithChildrenAsync(IEnumerable<string> names, CancellationToken)` method on `ITemplateEngineRepository` and implemented it on `TemplateEngineRepository` with a single `Where(t => names.Contains(t.Name))` + `Include(Attributes/Alarms/Scripts/Compositions).AsSplitQuery()` round-trip; null / empty / duplicate names are filtered before the IN clause. `BundleImporter.PreviewAsync` now calls the bulk method once with `content.Templates.Select(t => t.Name)` and indexes the result by name, replacing the previous `GetAllTemplatesAsync` scan + per-match `GetTemplateWithChildrenAsync(stub.Id)` round-trip pair. Regression tests: `TemplateEngineRepositoryTests.GetTemplatesWithChildrenAsync_*` (4 tests — bulk fetch, empty input, null enumerable, dedup) and `BundleImporterPreviewTests.PreviewAsync_multiple_templates_with_children_diffs_each_correctly` (asserts the bulk fetch hydrates all three templates' Attributes / Alarms / Scripts so the diff classifies each as Identical).
**Description**
Building the per-template diff loops over every existing stub returned by
@@ -40,6 +40,18 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
private const int SqliteErrorConstraint = 19;
private readonly SqliteConnection _connection;
// AuditLog-005: dedicated read-only connection used by GetBacklogStatsAsync,
// ReadPendingAsync, ReadPendingSinceAsync, and ReadForwardedAsync so a slow
// backlog scan (COUNT(*) over hundreds of thousands of Pending rows under a
// central outage) never parks the hot-path writer behind _writeLock.
// SQLite-with-WAL allows a second connection on the same file to read
// concurrently with the writer; the writer's WAL pragma is set in
// InitializeSchema before this connection is opened. The reader connection
// has its own _readLock because SqliteConnection itself is not thread-safe
// even in read-only mode — multiple read callers can otherwise interleave
// commands on the shared connection.
private readonly SqliteConnection _readConnection;
private readonly object _readLock = new();
private readonly SqliteAuditWriterOptions _options;
private readonly ILogger<SqliteAuditWriter> _logger;
private readonly INodeIdentityProvider _nodeIdentity;
@@ -74,6 +86,17 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
InitializeSchema();
// AuditLog-005: open a second connection for read-only callers
// (GetBacklogStatsAsync, ReadPendingAsync, ReadPendingSinceAsync,
// ReadForwardedAsync). InitializeSchema set journal_mode=WAL on the
// writer connection, which is a database-level setting that persists
// for the file — subsequent connections to the same file see WAL and
// can read concurrently with the writer without taking _writeLock.
// Reuse the same connection string so the read connection sees the
// same Data Source / Cache settings as the writer.
_readConnection = new SqliteConnection(connectionString);
_readConnection.Open();
_writeQueue = Channel.CreateBounded<PendingAuditEvent>(
new BoundedChannelOptions(_options.ChannelCapacity)
{
@@ -100,6 +123,23 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
pragmaCmd.ExecuteNonQuery();
}
// AuditLog-005: enable WAL so a second connection on the same file can
// serve read-only callers (GetBacklogStatsAsync, ReadPendingAsync,
// ReadPendingSinceAsync, ReadForwardedAsync) concurrently with the
// batched writer, decoupling those reads from _writeLock. WAL is a
// database-level setting persisted in the file header; setting it on
// the writer connection means every connection opened to the file
// afterwards inherits WAL behaviour. PRAGMA journal_mode returns the
// mode actually adopted ("memory" for ":memory:" / shared-cache memory
// mode, "wal" for file-backed) — we don't error if WAL was rejected
// because the read connection's correctness does not depend on WAL
// itself, only its concurrency advantage does.
using (var pragmaCmd = _connection.CreateCommand())
{
pragmaCmd.CommandText = "PRAGMA journal_mode = WAL";
pragmaCmd.ExecuteNonQuery();
}
using var cmd = _connection.CreateCommand();
cmd.CommandText = """
CREATE TABLE IF NOT EXISTS AuditLog (
@@ -392,14 +432,18 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
throw new ArgumentOutOfRangeException(nameof(limit), "limit must be > 0.");
}
// SqliteConnection is not thread-safe so we go through the same write
// lock the batch INSERTer uses. The actor caller is single-threaded,
// so contention is bounded.
lock (_writeLock)
// AuditLog-005: read via the dedicated _readConnection so this scan
// (which can be expensive when the backlog grows under a central
// outage) does not block the batched writer on _writeLock. WAL mode
// gives us a stable snapshot of the table while writes proceed on the
// writer connection. _readLock serialises this connection across
// multiple concurrent read callers since SqliteConnection itself is
// not thread-safe.
lock (_readLock)
{
ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _connection.CreateCommand();
using var cmd = _readConnection.CreateCommand();
cmd.CommandText = """
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target,
@@ -445,12 +489,14 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
throw new ArgumentOutOfRangeException(nameof(limit), "limit must be > 0.");
}
// Mirror ReadPendingAsync: the write lock guards the single connection.
lock (_writeLock)
// AuditLog-005: mirror ReadPendingAsync — read via _readConnection /
// _readLock so this query never contends with the batched writer on
// _writeLock.
lock (_readLock)
{
ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _connection.CreateCommand();
using var cmd = _readConnection.CreateCommand();
cmd.CommandText = """
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target,
@@ -520,12 +566,13 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
throw new ArgumentOutOfRangeException(nameof(batchSize), "batchSize must be > 0.");
}
// Mirror ReadPendingAsync: the write lock guards the single connection.
lock (_writeLock)
// AuditLog-005: read via _readConnection / _readLock — same lock-
// decoupling as ReadPendingAsync.
lock (_readLock)
{
ObjectDisposedException.ThrowIf(_disposed, this);
using var cmd = _connection.CreateCommand();
using var cmd = _readConnection.CreateCommand();
cmd.CommandText = """
SELECT EventId, OccurredAtUtc, Channel, Kind, CorrelationId,
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target,
@@ -599,7 +646,13 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
int pendingCount;
DateTime? oldestPending;
lock (_writeLock)
// AuditLog-005: read via the dedicated _readConnection (under
// _readLock) so this probe — polled every 30 s by SiteAuditBacklogReporter
// — never blocks the batched hot-path writer on _writeLock. Under a
// central outage the Pending backlog can grow to hundreds of thousands
// of rows and the COUNT(*) scan correspondingly stretches; that no
// longer adds tail latency to user-facing audit writes.
lock (_readLock)
{
ObjectDisposedException.ThrowIf(_disposed, this);
@@ -607,7 +660,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
// index range avoids a second scan. The IX_SiteAuditLog_ForwardState_Occurred
// index makes both aggregates cheap (count is a covering scan, min
// is the first key).
using var cmd = _connection.CreateCommand();
using var cmd = _readConnection.CreateCommand();
cmd.CommandText = """
SELECT COUNT(*), MIN(OccurredAtUtc)
FROM AuditLog
@@ -758,6 +811,15 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable
_disposed = true;
_connection.Dispose();
}
// AuditLog-005: dispose the dedicated read connection after the writer
// is fully drained and closed. _readLock is taken to fence out any
// in-flight read caller that grabbed the lock before _disposed flipped
// — they observe ObjectDisposedException on the next attempt.
lock (_readLock)
{
_readConnection.Dispose();
}
}
/// <summary>An audit event awaiting persistence by the background writer.</summary>
+64 -3
View File
@@ -121,9 +121,15 @@ public static class BundleCommands
using var doc = JsonDocument.Parse(jsonOk);
var base64 = doc.RootElement.GetProperty("base64Bundle").GetString()!;
var byteCount = doc.RootElement.GetProperty("byteCount").GetInt32();
var bytes = Convert.FromBase64String(base64);
File.WriteAllBytes(output, bytes);
Console.WriteLine($"Wrote {bytes.Length:N0} bytes to {output} (server reported {byteCount:N0}).");
// CLI-019: stream the base64 → file write so a 100 MB bundle
// doesn't double-buffer through Convert.FromBase64String's
// ~100 MB byte[] on the LOH plus a synchronous File.WriteAllBytes.
// The management envelope's body is still buffered into the
// jsonOk string (wire-format limit), but the decode + write
// are now chunked, so peak working-set drops from
// ~base64+byte[]+envelope to ~base64+small-chunk.
var written = StreamBase64ToFile(base64, output);
Console.WriteLine($"Wrote {written:N0} bytes to {output} (server reported {byteCount:N0}).");
return 0;
});
});
@@ -250,6 +256,61 @@ public static class BundleCommands
// the longer BundleCommandTimeout and a per-command success handler, so the
// exit-code contract is unified across every command group.
// CLI-019: chunked base64 → file streaming. The management envelope's
// success body is a single buffered JSON string (the wire format does not
// currently support response-body streaming), so we cannot remove the
// ~base64-string + ~envelope-string allocation. What we CAN — and do —
// remove is the intermediate ~bytecount-sized byte[] that
// Convert.FromBase64String allocates plus the synchronous File.WriteAllBytes:
// we slice the base64 string into 4-byte-multiple chunks (4 base64 chars
// decode into exactly 3 bytes, so any multiple of 4 is a clean boundary)
// and decode each chunk into a small rented buffer that we copy into the
// output FileStream. The chunk size is a tradeoff — large enough that the
// per-chunk loop overhead is negligible, small enough that we never put
// anything on the LOH (1 MB is below the 85 KB LOH threshold's larger
// cousin for buffers we don't keep). Returns the total decoded byte count
// for the post-write summary line.
internal const int Base64StreamChunkChars = 1024 * 1024; // 1 MB of base64 chars ≈ 768 KB decoded
internal static long StreamBase64ToFile(string base64, string outputPath)
{
if (base64 is null) throw new ArgumentNullException(nameof(base64));
if (string.IsNullOrEmpty(outputPath)) throw new ArgumentException("Output path required.", nameof(outputPath));
// Skip any leading whitespace and trailing padding noise. Convert.TryFromBase64Chars
// tolerates internal whitespace, but slicing on arbitrary positions would split a
// run of base64 chars mid-quad — round the chunk to a multiple of 4 so each slice
// is independently decodable.
var chunkChars = Base64StreamChunkChars - (Base64StreamChunkChars % 4);
var totalChars = base64.Length;
var totalWritten = 0L;
using var fileStream = new FileStream(
outputPath, FileMode.Create, FileAccess.Write, FileShare.None,
bufferSize: 81920, useAsync: false);
// 4 base64 chars = 3 bytes, so the decoded buffer is sized accordingly.
var byteBuffer = new byte[(chunkChars / 4) * 3];
for (var offset = 0; offset < totalChars; offset += chunkChars)
{
var take = Math.Min(chunkChars, totalChars - offset);
var slice = base64.AsSpan(offset, take);
// The final slice may be shorter than chunkChars and may carry
// trailing '=' padding; TryFromBase64Chars handles that.
if (!Convert.TryFromBase64Chars(slice, byteBuffer, out var written))
{
throw new FormatException(
$"Bundle response contained invalid base64 at character offset {offset}.");
}
fileStream.Write(byteBuffer, 0, written);
totalWritten += written;
}
return totalWritten;
}
private static Option<IReadOnlyList<string>?> NameListOption(string name, string description)
{
var opt = new Option<IReadOnlyList<string>?>(name)
@@ -103,7 +103,7 @@
<div class="text-muted small fst-italic">Reading bundle…</div>
}
@if (_bundleBytes is not null && _errorMessage is null)
@if (_bundleTempPath is not null && _errorMessage is null)
{
@if (_session is not null)
{
@@ -39,11 +39,17 @@ namespace ScadaLink.CentralUI.Components.Pages.Design;
///
/// Cached bundle bytes: because <see cref="IBundleImporter.LoadAsync"/> currently
/// peeks the manifest by attempting decryption, encrypted bundles require two
/// LoadAsync invocations. We cache the raw bytes in <c>_bundleBytes</c> after the
/// first read so the user does not need to re-select the file before entering the
/// passphrase. The bytes are cleared on Done / Back-to-Upload.
/// LoadAsync invocations. CentralUI-031: we previously cached the raw bytes in a
/// <c>byte[] _bundleBytes</c> field, which buffered the full upload (default cap
/// 100 MB) in the component's per-circuit state — multiplied across concurrent
/// operator sessions, that produced real central-node memory pressure. The
/// bytes are now streamed once to a per-session temp file under
/// <c>Path.GetTempPath()/scadalink-transport-staging/</c> and only the path is
/// retained on the component. The file is deleted on Back-to-Upload / Reset /
/// successful Apply / component Dispose, so an abandoned wizard does not leak
/// staged bundle plaintext beyond circuit teardown.
/// </summary>
public partial class TransportImport : ComponentBase
public partial class TransportImport : ComponentBase, IDisposable
{
public enum ImportWizardStep
{
@@ -66,13 +72,23 @@ public partial class TransportImport : ComponentBase
private ImportWizardStep _step = ImportWizardStep.Upload;
private string? _errorMessage;
// ---- Session + cached bytes ----
// Bundle bytes are cached so the same file can be re-attempted with a
// passphrase without forcing the user to re-pick it. Cleared in ResetAll.
private byte[]? _bundleBytes;
// ---- Session + cached bundle path ----
// CentralUI-031: the upload is streamed to a per-session temp file and only
// the path is retained on the component, so we don't hold an entire bundle
// (up to MaxBundleSizeMb, default 100 MB) in per-circuit memory across the
// wizard's lifetime. The file is deleted on every wizard reset path and on
// component disposal so an abandoned wizard cannot leak staged plaintext
// beyond circuit teardown.
private string? _bundleTempPath;
private BundleSession? _session;
private bool _uploadInProgress;
// Staging directory for in-flight bundle uploads. Lives under the system
// temp directory rather than wwwroot/ because the file is never served to
// a browser — it is only read by the in-process IBundleImporter.
private static readonly string StagingDir =
Path.Combine(Path.GetTempPath(), "scadalink-transport-staging");
// ---- Step 2: passphrase ----
private string _passphrase = string.Empty;
private int _failedUnlockAttempts;
@@ -106,7 +122,7 @@ public partial class TransportImport : ComponentBase
_errorMessage = null;
_uploadInProgress = true;
_session = null;
_bundleBytes = null;
DeleteBundleTempFile();
try
{
var maxBytes = Options.Value.MaxBundleSizeMb * 1024L * 1024L;
@@ -116,13 +132,21 @@ public partial class TransportImport : ComponentBase
return;
}
// CentralUI-031: stream the upload directly to a per-session temp
// file so the central node's working set is bounded by the
// FileStream buffer (~80 KB) rather than the full bundle bytes.
// OpenReadStream's MaxAllowedSize defaults to 500_000 bytes — bump
// it to the configured cap so the read doesn't throw before we get
// to the importer's own length check.
using var fileStream = e.File.OpenReadStream(maxBytes);
using var ms = new MemoryStream();
await fileStream.CopyToAsync(ms);
_bundleBytes = ms.ToArray();
Directory.CreateDirectory(StagingDir);
_bundleTempPath = Path.Combine(StagingDir, $"{Guid.NewGuid():N}.scadabundle");
using (var fileStream = e.File.OpenReadStream(maxBytes))
await using (var dest = new FileStream(
_bundleTempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
bufferSize: 81920, useAsync: true))
{
await fileStream.CopyToAsync(dest);
}
await TryLoadAsync(passphrase: null);
}
@@ -150,14 +174,19 @@ public partial class TransportImport : ComponentBase
/// </summary>
private async Task TryLoadAsync(string? passphrase)
{
if (_bundleBytes is null)
if (_bundleTempPath is null || !File.Exists(_bundleTempPath))
{
_errorMessage = "No bundle bytes cached — please re-select the file.";
_errorMessage = "No bundle staged — please re-select the file.";
return;
}
try
{
using var stream = new MemoryStream(_bundleBytes);
// CentralUI-031: read the staged bundle straight off disk; the
// importer's LoadAsync only walks the stream forward, so a plain
// FileStream is sufficient (no need to buffer it back into memory).
using var stream = new FileStream(
_bundleTempPath, FileMode.Open, FileAccess.Read, FileShare.Read,
bufferSize: 81920, useAsync: true);
_session = await BundleImporter.LoadAsync(stream, passphrase, CancellationToken.None);
_errorMessage = null;
}
@@ -528,7 +557,7 @@ public partial class TransportImport : ComponentBase
private void ResetSessionState()
{
_session = null;
_bundleBytes = null;
DeleteBundleTempFile();
_preview = null;
_resolutions = null;
_passphrase = string.Empty;
@@ -536,4 +565,44 @@ public partial class TransportImport : ComponentBase
_result = null;
_validationErrors = null;
}
/// <summary>
/// CentralUI-031: deletes the staged bundle temp file if any. Swallows IO
/// failures — an undeletable temp file is best-effort cleanup and must not
/// block the wizard.
/// </summary>
private void DeleteBundleTempFile()
{
var path = _bundleTempPath;
_bundleTempPath = null;
if (path is null) return;
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch (IOException)
{
// Another handle may still be open (e.g. an in-flight LoadAsync
// read). Leave the file behind; the OS temp dir is reaped on its
// own schedule. Audit-failure-style: never block the user-facing
// action.
}
catch (UnauthorizedAccessException)
{
// Same rationale.
}
}
/// <summary>
/// CentralUI-031: ensures the staged temp file does not survive circuit
/// teardown. Blazor invokes Dispose when the user navigates away or the
/// circuit ends, so an abandoned wizard cleans up automatically.
/// </summary>
public void Dispose()
{
DeleteBundleTempFile();
}
}
@@ -15,6 +15,18 @@ public interface ITemplateEngineRepository
/// <param name="id">The template ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<Template?> GetTemplateWithChildrenAsync(int id, CancellationToken cancellationToken = default);
/// <summary>
/// Bulk variant of <see cref="GetTemplateWithChildrenAsync(int, CancellationToken)"/>
/// that fetches every template whose <see cref="Template.Name"/> matches one of
/// <paramref name="names"/> in a single SQL/EF query, eager-loading
/// Attributes / Alarms / Scripts / Compositions. Resolves the Transport-008
/// N+1 in <c>BundleImporter.PreviewAsync</c> — names that don't match an
/// existing template are omitted from the result rather than producing a
/// null entry, so callers should look up by name into the returned list.
/// </summary>
/// <param name="names">Template names to load. Duplicate / null / empty names are filtered out.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<Template>> GetTemplatesWithChildrenAsync(IEnumerable<string> names, CancellationToken cancellationToken = default);
/// <summary>Retrieves all templates.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<Template>> GetAllTemplatesAsync(CancellationToken cancellationToken = default);
@@ -39,6 +39,30 @@ public class TemplateEngineRepository : ITemplateEngineRepository
return await GetTemplateByIdAsync(id, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Template>> GetTemplatesWithChildrenAsync(
IEnumerable<string> names, CancellationToken cancellationToken = default)
{
// Transport-008: bulk lookup replaces the per-name N+1 in
// BundleImporter.PreviewAsync. Filter out null / empty / duplicate
// names before the query so EF emits a clean, deduplicated IN clause.
if (names is null) return Array.Empty<Template>();
var distinct = names
.Where(n => !string.IsNullOrEmpty(n))
.Distinct(StringComparer.Ordinal)
.ToArray();
if (distinct.Length == 0) return Array.Empty<Template>();
return await _context.Templates
.Where(t => distinct.Contains(t.Name))
.Include(t => t.Attributes)
.Include(t => t.Alarms)
.Include(t => t.Scripts)
.Include(t => t.Compositions)
.AsSplitQuery()
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Template>> GetAllTemplatesAsync(CancellationToken cancellationToken = default)
{
@@ -79,16 +79,75 @@ public class ArtifactDeploymentService
/// single-site retries).
/// </param>
/// <returns>A deployment artifacts command for the site.</returns>
/// <remarks>
/// DeploymentManager-023: this convenience overload runs the global artifact queries
/// for a single site (used by <see cref="RetryForSiteAsync"/>). The multi-site
/// <see cref="DeployToAllSitesAsync"/> path hoists the global queries OUT of the
/// per-site loop and calls the prefetched-globals overload to avoid the N+1
/// re-query of every system-wide artifact set per site.
/// </remarks>
public async Task<DeployArtifactsCommand> BuildDeployArtifactsCommandAsync(
int siteId,
CancellationToken cancellationToken = default,
string? deploymentId = null)
{
var globals = await FetchGlobalArtifactsAsync(cancellationToken);
return await BuildDeployArtifactsCommandAsync(siteId, globals, cancellationToken, deploymentId);
}
/// <summary>
/// Builds a per-site <see cref="DeployArtifactsCommand"/> using a previously-fetched
/// snapshot of the global artifact sets (shared scripts, external systems + methods,
/// DB connections, notification lists, SMTP configurations). Only the per-site
/// data-connection query runs here.
/// </summary>
/// <remarks>
/// DeploymentManager-023: separating the global fetch from the per-site build lets
/// <see cref="DeployToAllSitesAsync"/> issue the global queries exactly once across
/// the whole multi-site sweep, eliminating the N+1 re-query of shared scripts,
/// external systems, methods, DB connections, notification lists, and SMTP
/// configurations.
/// </remarks>
private async Task<DeployArtifactsCommand> BuildDeployArtifactsCommandAsync(
int siteId,
GlobalArtifactSnapshot globals,
CancellationToken cancellationToken,
string? deploymentId)
{
var dataConnections = await _siteRepo.GetDataConnectionsBySiteIdAsync(siteId, cancellationToken);
// Map data connections
var dataConnectionArtifacts = dataConnections.Select(dc =>
new DataConnectionArtifact(dc.Name, dc.Protocol, dc.PrimaryConfiguration, dc.BackupConfiguration, dc.FailoverRetryCount)).ToList();
return new DeployArtifactsCommand(
deploymentId ?? Guid.NewGuid().ToString("N"),
globals.SharedScripts,
globals.ExternalSystems,
globals.DatabaseConnections,
globals.NotificationLists,
dataConnectionArtifacts,
globals.SmtpConfigurations,
DateTimeOffset.UtcNow);
}
/// <summary>
/// Fetches the system-wide artifact sets that are identical across every site —
/// shared scripts, external systems (with their methods serialized in), database
/// connections, notification lists, and SMTP configurations. Used by
/// <see cref="DeployToAllSitesAsync"/> to pre-load once before the per-site loop.
/// </summary>
/// <remarks>
/// DeploymentManager-023: the per-site artifact build path previously re-issued
/// every one of these queries per site (≈ 5·N + M·N round trips for N sites
/// and M external systems). Hoisting them here drops that to a single fetch.
/// </remarks>
private async Task<GlobalArtifactSnapshot> FetchGlobalArtifactsAsync(CancellationToken cancellationToken)
{
var sharedScripts = await _templateRepo.GetAllSharedScriptsAsync(cancellationToken);
var externalSystems = await _externalSystemRepo.GetAllExternalSystemsAsync(cancellationToken);
var dbConnections = await _externalSystemRepo.GetAllDatabaseConnectionsAsync(cancellationToken);
var notificationLists = await _notificationRepo.GetAllNotificationListsAsync(cancellationToken);
var dataConnections = await _siteRepo.GetDataConnectionsBySiteIdAsync(siteId, cancellationToken);
var smtpConfigurations = await _notificationRepo.GetAllSmtpConfigurationsAsync(cancellationToken);
// Map shared scripts
@@ -122,27 +181,32 @@ public class ArtifactDeploymentService
var notificationListArtifacts = notificationLists.Select(nl =>
new NotificationListArtifact(nl.Name, nl.Recipients.Select(r => r.EmailAddress).ToList())).ToList();
// Map data connections
var dataConnectionArtifacts = dataConnections.Select(dc =>
new DataConnectionArtifact(dc.Name, dc.Protocol, dc.PrimaryConfiguration, dc.BackupConfiguration, dc.FailoverRetryCount)).ToList();
// Map SMTP configurations — use Host as the artifact name (matches SQLite PK on site)
var smtpArtifacts = smtpConfigurations.Select(smtp =>
new SmtpConfigurationArtifact(
$"{smtp.Host}:{smtp.Port}", smtp.Host, smtp.Port, smtp.AuthType, smtp.FromAddress,
smtp.Credentials, null, smtp.TlsMode)).ToList();
return new DeployArtifactsCommand(
deploymentId ?? Guid.NewGuid().ToString("N"),
return new GlobalArtifactSnapshot(
scriptArtifacts,
externalSystemArtifacts,
dbConnectionArtifacts,
notificationListArtifacts,
dataConnectionArtifacts,
smtpArtifacts,
DateTimeOffset.UtcNow);
smtpArtifacts);
}
/// <summary>
/// Bag of the global artifact sets that do not vary per site, captured once at
/// the start of <see cref="DeployToAllSitesAsync"/> and reused for every per-site
/// command build (DeploymentManager-023).
/// </summary>
private sealed record GlobalArtifactSnapshot(
IReadOnlyList<SharedScriptArtifact> SharedScripts,
IReadOnlyList<ExternalSystemArtifact> ExternalSystems,
IReadOnlyList<DatabaseConnectionArtifact> DatabaseConnections,
IReadOnlyList<NotificationListArtifact> NotificationLists,
IReadOnlyList<SmtpConfigurationArtifact> SmtpConfigurations);
/// <summary>
/// Deploys artifacts to all sites. Builds a per-site command with that site's data connections.
/// Returns per-site result matrix.
@@ -161,6 +225,12 @@ public class ArtifactDeploymentService
var deploymentId = Guid.NewGuid().ToString("N");
var perSiteResults = new Dictionary<string, SiteArtifactResult>();
// DeploymentManager-023: hoist the system-wide artifact queries (shared scripts,
// external systems + methods, DB connections, notification lists, SMTP configs)
// OUT of the per-site loop so they run ONCE instead of once per site. Only
// data connections legitimately vary per site, so they stay inside the loop.
var globals = await FetchGlobalArtifactsAsync(cancellationToken);
// Build per-site commands sequentially (DbContext is not thread-safe).
// DeploymentManager-010: every per-site command carries the SAME logical
// deploymentId, so the per-site commands, audit log, persisted record,
@@ -169,7 +239,7 @@ public class ArtifactDeploymentService
foreach (var site in sites)
{
siteCommands[site.Id] = await BuildDeployArtifactsCommandAsync(
site.Id, cancellationToken, deploymentId);
site.Id, globals, cancellationToken, deploymentId);
}
// Deploy to each site in parallel with per-site timeout
@@ -138,9 +138,24 @@ public sealed class AuditWriteMiddleware
// stream for a seekable wrapper that the framework rewinds at the end
// of the pipeline for us — but we also rewind to position 0 after our
// own read so the very next reader starts from the top.
ctx.Request.EnableBuffering();
var (requestBody, requestTruncated) =
await ReadBufferedRequestBodyAsync(ctx.Request, cap).ConfigureAwait(false);
//
// InboundAPI-019: skip EnableBuffering for bodyless requests (a known
// empty Content-Length or a method that conventionally carries no body —
// GET / HEAD / DELETE / TRACE / OPTIONS). The FileBufferingReadStream
// wrapper EnableBuffering installs allocates an internal buffer regardless
// of whether the request actually has a body; bodyless inbound traffic
// (e.g. GET /api/audit/query, health probes) no longer pays that cost.
// ReadBufferedRequestBodyAsync's own ContentLength is 0 short-circuit
// returns (null, false) for the bodyless case anyway, so the audit row
// is unchanged.
var requestBody = (string?)null;
var requestTruncated = false;
if (RequestHasBody(ctx.Request))
{
ctx.Request.EnableBuffering();
(requestBody, requestTruncated) =
await ReadBufferedRequestBodyAsync(ctx.Request, cap).ConfigureAwait(false);
}
// Response body — wrap Response.Body in a forwarding stream that mirrors
// every write to the original sink (transparent to the real client)
@@ -301,6 +316,31 @@ public sealed class AuditWriteMiddleware
TaskScheduler.Default);
}
/// <summary>
/// InboundAPI-019: decides whether the request is likely to carry a body, so the
/// caller can skip <see cref="HttpRequestRewindExtensions.EnableBuffering(HttpRequest)"/>
/// (and the associated <c>FileBufferingReadStream</c> allocation) on requests that
/// definitely won't have one. Returns <c>true</c> when <see cref="HttpRequest.ContentLength"/>
/// is positive OR when the HTTP method is one that conventionally carries a body
/// (POST / PUT / PATCH). Bodyless methods (GET / HEAD / DELETE / TRACE / OPTIONS)
/// with an absent or zero Content-Length return <c>false</c> — those are the
/// requests that previously paid the buffering allocation for no benefit. A
/// body-carrying method with no Content-Length (e.g. chunked transfer-encoding)
/// still buffers, so streamed POST bodies are unaffected.
/// </summary>
private static bool RequestHasBody(HttpRequest request)
{
if (request.ContentLength is > 0)
{
return true;
}
var method = request.Method;
return HttpMethods.IsPost(method)
|| HttpMethods.IsPut(method)
|| HttpMethods.IsPatch(method);
}
/// <summary>
/// Reads the buffered request body up to <paramref name="capBytes"/> bytes
/// into a string for the audit copy and rewinds the stream so the
@@ -1287,18 +1287,28 @@ public class ManagementActor : ReceiveActor
var permittedIds = new HashSet<string>(user.PermittedSiteIds);
var templateRepo = sp.GetRequiredService<ITemplateEngineRepository>();
var instanceSiteCache = new Dictionary<int, int?>();
// ManagementService-023: pre-load all instances ONCE via the repository's
// bulk method and build an InstanceId -> SiteId? lookup, instead of issuing
// GetInstanceByIdAsync per distinct record.InstanceId (textbook N+1). The
// unfiltered branch now hits the configuration database exactly twice
// (deployment records + instances) regardless of fleet size.
var allInstances = await templateRepo.GetAllInstancesAsync();
var instanceSiteLookup = new Dictionary<int, int?>(allInstances.Count);
foreach (var instance in allInstances)
{
instanceSiteLookup[instance.Id] = instance.SiteId;
}
var scoped = new List<DeploymentRecord>();
foreach (var record in records)
{
if (!instanceSiteCache.TryGetValue(record.InstanceId, out var siteId))
if (instanceSiteLookup.TryGetValue(record.InstanceId, out var siteId)
&& siteId.HasValue
&& permittedIds.Contains(siteId.Value.ToString()))
{
var instance = await templateRepo.GetInstanceByIdAsync(record.InstanceId);
siteId = instance?.SiteId;
instanceSiteCache[record.InstanceId] = siteId;
}
if (siteId.HasValue && permittedIds.Contains(siteId.Value.ToString()))
scoped.Add(record);
}
}
return scoped;
}
@@ -49,6 +49,34 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
/// </summary>
private bool _dispatching;
/// <summary>
/// NotificationOutbox-006: cached <see cref="NotificationType"/> → adapter lookup, built
/// lazily on the first dispatch sweep and reused for the lifetime of the actor. The
/// adapter registration is decided at startup by <c>AddNotificationOutbox</c> (the set is
/// keyed by <see cref="NotificationType"/> and is static per process lifetime), so
/// rebuilding this dictionary on every sweep was pure allocation waste.
/// </summary>
/// <remarks>
/// The cache is paired with <see cref="_adaptersScope"/>, an actor-lifetime
/// <see cref="IServiceScope"/> created on first use so the cached scoped adapter
/// instances and their dependencies live as long as the cache itself. The scope is
/// disposed in <see cref="PostStop"/>. The adapters are stateless wrappers that
/// resolve their per-call collaborators (e.g. <see cref="INotificationRepository"/>'s
/// underlying DbContext) through their own injected dependencies; holding them for
/// the actor's lifetime is consistent with the actor's own singleton lifetime on the
/// active central node.
/// </remarks>
private IReadOnlyDictionary<NotificationType, INotificationDeliveryAdapter>? _adaptersCache;
/// <summary>
/// NotificationOutbox-006: actor-lifetime DI scope that owns the cached
/// <see cref="_adaptersCache"/> adapter instances. Created lazily on the first
/// dispatch sweep that needs adapters; disposed in <see cref="PostStop"/> so the
/// scoped adapter graph (and any disposable dependencies it transitively holds) is
/// torn down with the actor.
/// </summary>
private IServiceScope? _adaptersScope;
/// <summary>
/// NO-003: lifecycle-scoped cancellation source, cancelled in <see cref="PostStop"/> so
/// any in-flight dispatch sweep — including a long-running SMTP send via the channel
@@ -125,6 +153,14 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
_shutdownCts?.Dispose();
_shutdownCts = null;
// NotificationOutbox-006: dispose the actor-lifetime adapter scope so the cached
// scoped adapter instances and their disposable dependencies are torn down with
// the actor (e.g. on a CoordinatedShutdown / failover that stops the singleton).
_adaptersScope?.Dispose();
_adaptersScope = null;
_adaptersCache = null;
base.PostStop();
}
@@ -224,10 +260,12 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
/// and retry-policy resolution can all throw, and a faulted task would otherwise leave
/// the dispatcher's in-flight guard stuck and wedge the loop permanently.
///
/// The channel delivery adapters are resolved from the per-sweep scope, not held in a
/// field: <see cref="EmailNotificationDeliveryAdapter"/> takes a scoped
/// <see cref="INotificationRepository"/> directly, so a long-lived adapter reference on
/// this singleton actor would be a captive dependency over a disposed DbContext.
/// The per-sweep DI scope still owns the repository graph
/// (<see cref="INotificationOutboxRepository"/> + <see cref="INotificationRepository"/>),
/// which is correct because those services back a fresh DbContext per sweep. The
/// channel delivery adapters, however, are cached for the actor's lifetime via
/// <see cref="ResolveAdapters"/> — see <see cref="_adaptersCache"/> for the
/// NotificationOutbox-006 rationale.
/// </summary>
private async Task RunDispatchPass(DateTimeOffset now, CancellationToken cancellationToken)
{
@@ -236,7 +274,7 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
using var scope = _serviceProvider.CreateScope();
var outboxRepository = scope.ServiceProvider.GetRequiredService<INotificationOutboxRepository>();
var notificationRepository = scope.ServiceProvider.GetRequiredService<INotificationRepository>();
var adapters = ResolveAdapters(scope.ServiceProvider);
var adapters = ResolveAdapters();
IReadOnlyList<Notification> due;
try
@@ -348,21 +386,37 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
}
/// <summary>
/// Builds the <see cref="NotificationType"/> → adapter lookup for a dispatch sweep from
/// the registered <see cref="INotificationDeliveryAdapter"/> services in the supplied
/// scope. The last adapter registered for a given type wins, mirroring DI's last-wins
/// resolution semantics.
/// Returns the <see cref="NotificationType"/> → adapter lookup, building it lazily on
/// the first call and caching it on <see cref="_adaptersCache"/> for the actor's
/// lifetime. The last adapter registered for a given type wins, mirroring DI's
/// last-wins resolution semantics.
/// </summary>
private static IReadOnlyDictionary<NotificationType, INotificationDeliveryAdapter> ResolveAdapters(
IServiceProvider scopedServices)
/// <remarks>
/// NotificationOutbox-006: the lookup used to be rebuilt on every dispatch sweep
/// from the per-sweep DI scope. Adapter registration is static per process
/// lifetime, so the dict is now built ONCE — on the first sweep that needs it —
/// and reused. To respect each adapter's scoped lifetime
/// (<see cref="EmailNotificationDeliveryAdapter"/> takes a scoped
/// <see cref="INotificationRepository"/>), the cache is paired with
/// <see cref="_adaptersScope"/>, an actor-lifetime <see cref="IServiceScope"/> that
/// owns the cached adapter instances and is disposed in <see cref="PostStop"/>.
/// </remarks>
private IReadOnlyDictionary<NotificationType, INotificationDeliveryAdapter> ResolveAdapters()
{
if (_adaptersCache is not null)
{
return _adaptersCache;
}
_adaptersScope = _serviceProvider.CreateScope();
var adapters = new Dictionary<NotificationType, INotificationDeliveryAdapter>();
foreach (var adapter in scopedServices.GetServices<INotificationDeliveryAdapter>())
foreach (var adapter in _adaptersScope.ServiceProvider.GetServices<INotificationDeliveryAdapter>())
{
adapters[adapter.Type] = adapter;
}
return adapters;
_adaptersCache = adapters;
return _adaptersCache;
}
/// <summary>
@@ -9,8 +9,39 @@ namespace ScadaLink.NotificationService;
/// Supports OAuth2 Client Credentials (M365) and Basic Auth.
/// BCC delivery, plain text.
/// </summary>
/// <remarks>
/// <para>
/// <b>Lifetime — one wrapper, one delivery (NS-022).</b>
/// This wrapper owns a SINGLE underlying <see cref="MailKit.Net.Smtp.SmtpClient"/>
/// — it is NOT a connection pool. MailKit's <c>SmtpClient</c> is a single TCP/TLS
/// connection holder and is NOT thread-safe; reusing one across concurrent or
/// back-to-back deliveries without external synchronization is unsafe.
/// </para>
/// <para>
/// The DI registration (<c>AddSingleton&lt;Func&lt;ISmtpClientWrapper&gt;&gt;</c>)
/// is therefore a per-delivery FACTORY, not a singleton wrapper: callers
/// (<see cref="ScadaLink.NotificationOutbox.Delivery.EmailNotificationDeliveryAdapter"/>)
/// invoke the factory at the top of every <c>DeliverAsync</c>, run the
/// connect/authenticate/send/disconnect sequence on the fresh wrapper, and
/// dispose it at the end of the send. Each delivery pays a full TCP+TLS
/// handshake; this is the deliberate, documented cost of avoiding shared
/// connection state. The factory shape exists specifically so a future
/// pooled/synchronized implementation can be slotted in without changing
/// callers — but the current implementation deliberately does NOT pool.
/// </para>
/// <para>
/// Do not reuse one wrapper across deliveries. <see cref="ConnectAsync"/>
/// mutates <c>_client.Timeout</c> per call (NS-007), and the underlying
/// <c>SmtpClient</c> rejects concurrent send calls — both are latent footguns
/// for any caller tempted to "fix" the factory into a true singleton.
/// </para>
/// </remarks>
public class MailKitSmtpClientWrapper : ISmtpClientWrapper, IDisposable
{
// NS-022: ONE SmtpClient per wrapper — see class-level remarks. This is NOT a
// connection pool. MailKit's SmtpClient holds a single TCP/TLS connection and
// is not thread-safe; the wrapper is meant for a single connect/auth/send/
// disconnect cycle per instance, after which it MUST be disposed.
private readonly SmtpClient _client = new();
/// <inheritdoc />
@@ -52,8 +52,15 @@ public class SiteEventLogger : ISiteEventLogger, IDisposable
{
_logger = logger;
// SiteEventLogging-022: Cache=Shared is a cross-connection optimisation
// that lets multiple SqliteConnections share an in-process page cache.
// This logger owns exactly one SqliteConnection and serialises all
// access through _writeLock, so the mode is dormant — at best dead
// configuration, at worst a small future foot-gun for any second
// connection opened to the same file. A test path that genuinely
// needs Cache=Shared can still inject it via connectionStringOverride.
var connectionString = connectionStringOverride
?? $"Data Source={options.Value.DatabasePath};Cache=Shared";
?? $"Data Source={options.Value.DatabasePath}";
_connection = new SqliteConnection(connectionString);
_connection.Open();
@@ -350,24 +350,16 @@ public sealed class BundleImporter : IBundleImporter
}
// ---- Templates ----
// Repos only expose GetTemplateByIdAsync / GetAllTemplatesAsync — no
// by-name lookup. Pull all once and index by name for the diff loop.
var allTemplates = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
var hydratedByName = new Dictionary<string, Template>(StringComparer.Ordinal);
foreach (var stub in allTemplates)
{
// GetAllTemplatesAsync may not eager-load children — fetch the
// children-loaded variant for any name that matches an incoming DTO
// so the per-child diff loop sees the full collection.
if (content.Templates.Any(t => string.Equals(t.Name, stub.Name, StringComparison.Ordinal)))
{
var hydrated = await _templateRepo.GetTemplateWithChildrenAsync(stub.Id, ct).ConfigureAwait(false);
if (hydrated is not null)
{
hydratedByName[stub.Name] = hydrated;
}
}
}
// Transport-008: previously this loop iterated GetAllTemplatesAsync()
// and called GetTemplateWithChildrenAsync(stub.Id) once per matching
// name (classic N+1). The bulk variant fetches every matching template
// with children eager-loaded in a single round-trip.
var bundleTemplateNames = content.Templates.Select(t => t.Name);
var hydratedTemplates = await _templateRepo
.GetTemplatesWithChildrenAsync(bundleTemplateNames, ct)
.ConfigureAwait(false);
var hydratedByName = hydratedTemplates
.ToDictionary(t => t.Name, t => t, StringComparer.Ordinal);
foreach (var tDto in content.Templates)
{
hydratedByName.TryGetValue(tDto.Name, out var existing);
@@ -112,6 +112,59 @@ public class SqliteAuditWriterBacklogStatsTests : IDisposable
Assert.Equal(t1, snapshot.OldestPendingUtc!.Value);
}
[Fact]
public async Task GetBacklogStatsAsync_DoesNotBlockOnConcurrentWriteLoad()
{
// AuditLog-005: GetBacklogStatsAsync previously took _writeLock, the
// same lock that serialises every batch INSERT in FlushBatch. Under a
// backlog growing to hundreds of thousands of rows a COUNT(*)+MIN
// index scan could park the hot-path writer for hundreds of ms. The
// fix adds a dedicated read-only connection in WAL mode so the probe
// never contends with the writer.
//
// This test demonstrates the lock decoupling by saturating the writer
// with a burst of concurrent writes and asserting that a probe issued
// while those writes are in flight returns inside a tight time bound.
// Without the fix the probe would be queued behind FlushBatch under
// the same _writeLock; with the fix it reads through _readConnection
// and is not gated by the writer.
await using var writer = CreateWriter();
// Seed a baseline so MIN(OccurredAtUtc) has a row to find — the
// important assertion is timing, but a non-empty result also confirms
// the read connection sees the writer's commits via WAL.
for (var i = 0; i < 100; i++)
{
await writer.WriteAsync(NewEvent());
}
// Kick off a sustained write burst on a background task. The writes
// are fire-and-forget — we only need the writer to be busy enough
// that any reuse of _writeLock by the probe would be observable.
var burst = Task.Run(async () =>
{
for (var i = 0; i < 2_000; i++)
{
await writer.WriteAsync(NewEvent()).ConfigureAwait(false);
}
});
// Race the probe against the write burst. The probe must return
// promptly even though the writer is actively flushing batches.
var sw = System.Diagnostics.Stopwatch.StartNew();
var snapshot = await writer.GetBacklogStatsAsync();
sw.Stop();
// Drain the burst before disposing so we don't observe a flake when
// pending writes race with dispose.
await burst;
Assert.True(sw.ElapsedMilliseconds < 1_000,
$"GetBacklogStatsAsync must not block on the writer's _writeLock; took {sw.ElapsedMilliseconds} ms");
Assert.True(snapshot.PendingCount >= 100,
$"backlog probe should see at least the seeded rows; got {snapshot.PendingCount}");
}
[Fact]
public async Task OnDiskBytes_ReturnsFileSize()
{
@@ -0,0 +1,94 @@
using ScadaLink.CLI.Commands;
namespace ScadaLink.CLI.Tests.Commands;
/// <summary>
/// CLI-019 regression tests for <see cref="BundleCommands.StreamBase64ToFile"/>.
/// The pre-fix code did <c>Convert.FromBase64String(...) → File.WriteAllBytes(...)</c>,
/// doubling the bundle's bytes onto the LOH and writing synchronously. The new
/// streaming helper decodes the base64 string in fixed-size chunks straight into
/// a <see cref="FileStream"/>, so peak working set is bounded by the chunk size
/// regardless of how large the bundle is.
/// </summary>
public class BundleCommandsStreamingTests : IDisposable
{
private readonly string _tempPath;
public BundleCommandsStreamingTests()
{
_tempPath = Path.Combine(Path.GetTempPath(), $"bundle-stream-test-{Guid.NewGuid():N}.bin");
}
public void Dispose()
{
if (File.Exists(_tempPath))
{
File.Delete(_tempPath);
}
}
[Fact]
public void StreamBase64ToFile_SmallPayload_RoundTrips()
{
var bytes = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
var base64 = Convert.ToBase64String(bytes);
var written = BundleCommands.StreamBase64ToFile(base64, _tempPath);
Assert.Equal(bytes.Length, written);
var roundTripped = File.ReadAllBytes(_tempPath);
Assert.Equal(bytes, roundTripped);
}
[Fact]
public void StreamBase64ToFile_PayloadCrossesChunkBoundary_RoundTrips()
{
// Build a payload several chunks wide so the slicing loop runs more than
// once, with enough trailing bytes that the final slice is short and
// exercises the padding/short-final-chunk path.
var size = (BundleCommands.Base64StreamChunkChars / 4 * 3) * 3 + 17;
var bytes = new byte[size];
for (var i = 0; i < size; i++) bytes[i] = (byte)(i & 0xFF);
var base64 = Convert.ToBase64String(bytes);
var written = BundleCommands.StreamBase64ToFile(base64, _tempPath);
Assert.Equal(size, written);
var roundTripped = File.ReadAllBytes(_tempPath);
Assert.Equal(bytes, roundTripped);
}
[Fact]
public void StreamBase64ToFile_EmptyString_WritesEmptyFile()
{
var written = BundleCommands.StreamBase64ToFile(string.Empty, _tempPath);
Assert.Equal(0, written);
Assert.True(File.Exists(_tempPath));
Assert.Empty(File.ReadAllBytes(_tempPath));
}
[Fact]
public void StreamBase64ToFile_InvalidBase64_ThrowsFormatException()
{
// '*' is not a valid base64 character, so TryFromBase64Chars returns
// false and the helper throws — the pre-fix code threw FormatException
// from Convert.FromBase64String, so the contract is preserved.
var invalid = "this is not valid base64 !!!*";
Assert.Throws<FormatException>(() => BundleCommands.StreamBase64ToFile(invalid, _tempPath));
}
[Fact]
public void StreamBase64ToFile_NullBase64_Throws()
{
Assert.Throws<ArgumentNullException>(() => BundleCommands.StreamBase64ToFile(null!, _tempPath));
}
[Fact]
public void StreamBase64ToFile_EmptyOutputPath_Throws()
{
Assert.Throws<ArgumentException>(() => BundleCommands.StreamBase64ToFile("AAAA", string.Empty));
}
}
@@ -370,13 +370,20 @@ public class TransportImportPageTests : BunitContext
}
/// <summary>
/// Seeds the wizard at Step 2 (Passphrase) with cached bundle bytes — the
/// Seeds the wizard at Step 2 (Passphrase) with a staged bundle file — the
/// shape after an encrypted-bundle upload completed Step 1's peek and
/// surfaced an ArgumentException ("passphrase required").
/// surfaced an ArgumentException ("passphrase required"). CentralUI-031:
/// the wizard now stages the upload to a temp file and only retains the
/// path on the component, so the test helper writes the bytes to a per-
/// test temp file and sets the path field instead of the byte[] field.
/// </summary>
private static void SeedAtPassphraseStep(TransportImportPage instance, byte[] bytes)
{
SetField(instance, "_bundleBytes", bytes);
var dir = Path.Combine(Path.GetTempPath(), "scadalink-transport-staging");
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, $"test-{Guid.NewGuid():N}.scadabundle");
File.WriteAllBytes(path, bytes);
SetField(instance, "_bundleTempPath", path);
SetField(instance, "_session", null);
SetField(instance, "_step", TransportImportPage.ImportWizardStep.Passphrase);
SetField(instance, "_failedUnlockAttempts", 0);
@@ -69,6 +69,66 @@ public class TemplateEngineRepositoryTests : IDisposable
Assert.Null(loaded);
}
[Fact]
public async Task GetTemplatesWithChildrenAsync_BulkVariant_FetchesEveryMatchingNameInOneQuery()
{
// Transport-008 regression: BundleImporter.PreviewAsync previously
// called GetTemplateWithChildrenAsync(stub.Id) per matching template
// name. The bulk variant returns every match in a single query.
var a = new Template("Alpha");
a.Attributes.Add(new TemplateAttribute("A1"));
a.Scripts.Add(new TemplateScript("AS1", "return 1;"));
var b = new Template("Beta");
b.Alarms.Add(new TemplateAlarm("BAlarm"));
var c = new Template("Gamma");
_context.Templates.AddRange(a, b, c);
await _context.SaveChangesAsync();
var result = await _repository.GetTemplatesWithChildrenAsync(new[] { "Alpha", "Beta", "DoesNotExist" });
Assert.Equal(2, result.Count);
var loadedA = Assert.Single(result, t => t.Name == "Alpha");
var loadedB = Assert.Single(result, t => t.Name == "Beta");
Assert.Single(loadedA.Attributes);
Assert.Equal("A1", loadedA.Attributes.First().Name);
Assert.Single(loadedA.Scripts);
Assert.Single(loadedB.Alarms);
Assert.Equal("BAlarm", loadedB.Alarms.First().Name);
Assert.DoesNotContain(result, t => t.Name == "Gamma");
}
[Fact]
public async Task GetTemplatesWithChildrenAsync_EmptyNames_ReturnsEmpty()
{
var result = await _repository.GetTemplatesWithChildrenAsync(Array.Empty<string>());
Assert.Empty(result);
}
[Fact]
public async Task GetTemplatesWithChildrenAsync_NullEnumerable_ReturnsEmpty()
{
var result = await _repository.GetTemplatesWithChildrenAsync(null!);
Assert.Empty(result);
}
[Fact]
public async Task GetTemplatesWithChildrenAsync_FiltersOutDuplicatesAndEmptyStrings()
{
var a = new Template("Alpha");
_context.Templates.Add(a);
await _context.SaveChangesAsync();
// Duplicate names + empty / null entries should not throw; the helper
// deduplicates and filters them out before the SQL IN clause.
var result = await _repository.GetTemplatesWithChildrenAsync(
new[] { "Alpha", "Alpha", "", null!, "Alpha" });
Assert.Single(result);
Assert.Equal("Alpha", result[0].Name);
}
[Fact]
public async Task GetTemplateWithChildrenAsync_PreservesParentTemplateId_ForInheritanceChainWalk()
{
@@ -140,6 +140,63 @@ public class ArtifactDeploymentServiceTests : TestKit
Assert.Contains(result.Value.SiteResults, r => r.SiteId == "fail-site" && !r.Success);
}
// ── DeploymentManager-023: global artifact queries hoisted out of the per-site loop ──
[Fact]
public async Task DeployToAllSitesAsync_HoistsGlobalArtifactQueriesOutOfPerSiteLoop()
{
// DeploymentManager-023: previously each per-site iteration of the deploy-many
// loop re-issued the global artifact queries (shared scripts, external systems,
// DB connections, notification lists, SMTP configs) — a textbook N+1 over the
// global sets. With three sites the queries must now be issued ONCE in total,
// regardless of site count.
var sites = new List<Site>
{
new("Site One", "site-1") { Id = 1 },
new("Site Two", "site-2") { Id = 2 },
new("Site Three", "site-3") { Id = 3 },
};
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(sites);
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor()));
var service = CreateServiceWithCommActor(probe);
var result = await service.DeployToAllSitesAsync("admin");
Assert.True(result.IsSuccess);
// Each global query must be called EXACTLY ONCE for the whole multi-site sweep.
await _templateRepo.Received(1).GetAllSharedScriptsAsync(Arg.Any<CancellationToken>());
await _externalSystemRepo.Received(1).GetAllExternalSystemsAsync(Arg.Any<CancellationToken>());
await _externalSystemRepo.Received(1).GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>());
await _notificationRepo.Received(1).GetAllNotificationListsAsync(Arg.Any<CancellationToken>());
await _notificationRepo.Received(1).GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>());
// The per-site query (data connections) DOES vary per site and must still run
// once per site.
await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>());
await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>());
await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(3, Arg.Any<CancellationToken>());
}
[Fact]
public async Task RetryForSiteAsync_SingleSitePath_StillRunsTheGlobalQueriesOnce()
{
// DeploymentManager-023: the single-site convenience overload still owns its
// own global-fetch (it cannot inherit from a sweep), so for one site every
// global query is issued exactly once. Pin this so a future refactor cannot
// accidentally route RetryForSiteAsync through the multi-site loop and lose
// the audit row's deploymentId guarantee.
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor()));
var service = CreateServiceWithCommActor(probe);
var result = await service.RetryForSiteAsync(1, "retry-site", "admin");
Assert.True(result.IsSuccess);
await _templateRepo.Received(1).GetAllSharedScriptsAsync(Arg.Any<CancellationToken>());
await _externalSystemRepo.Received(1).GetAllExternalSystemsAsync(Arg.Any<CancellationToken>());
await _externalSystemRepo.Received(1).GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>());
await _notificationRepo.Received(1).GetAllNotificationListsAsync(Arg.Any<CancellationToken>());
await _notificationRepo.Received(1).GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task RetryForSiteAsync_SiteSucceeds_ReturnsSuccessAndAudits()
{
@@ -800,4 +800,109 @@ public class AuditWriteMiddlewareTests
"Expected a Warning log entry observing the async audit-write fault — none found. " +
$"Entries: [{string.Join(", ", snapshot)}]");
}
// ---------------------------------------------------------------------
// InboundAPI-019 — bodyless requests skip EnableBuffering so the
// FileBufferingReadStream allocation is avoided on GET/HEAD/DELETE
// and any request whose Content-Length is 0. The audit row still emits
// with a null RequestSummary, mirroring the bodyless-POST contract.
// ---------------------------------------------------------------------
[Theory]
[InlineData("GET")]
[InlineData("HEAD")]
[InlineData("DELETE")]
public async Task BodylessMethod_SkipsEnableBuffering_RequestStreamIsNotReplaced(string method)
{
// The middleware previously called EnableBuffering on every request,
// installing a FileBufferingReadStream wrapper even when the request
// had no body. The bodyless-method short-circuit must leave
// Request.Body untouched (still the original empty stream the test
// assigns below), proving the buffering wrapper allocation is avoided.
var writer = new RecordingAuditWriter();
var ctx = new DefaultHttpContext();
ctx.Request.Method = method;
ctx.Request.Path = "/api/echo";
ctx.Request.RouteValues["methodName"] = "echo";
ctx.Connection.RemoteIpAddress = IPAddress.Parse("10.0.0.5");
// Distinct sentinel stream — the production code path that called
// EnableBuffering would replace this with FileBufferingReadStream.
// After the fix the original stream survives untouched.
var sentinel = new MemoryStream();
ctx.Request.Body = sentinel;
Stream? observedDuringHandler = null;
var mw = CreateMiddleware(hc =>
{
observedDuringHandler = hc.Request.Body;
hc.Response.StatusCode = 200;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(ctx);
Assert.Same(sentinel, observedDuringHandler);
var evt = Assert.Single(writer.Events);
// No body → RequestSummary stays null, matching the bodyless-POST contract.
Assert.Null(evt.RequestSummary);
}
[Fact]
public async Task BodylessPost_ContentLengthZero_SkipsEnableBuffering()
{
// A POST with an explicit Content-Length of 0 is also bodyless — even
// though POST is conventionally a body-carrying method, the explicit
// zero short-circuits buffering. This pins the ContentLength branch of
// the RequestHasBody guard.
var writer = new RecordingAuditWriter();
var ctx = new DefaultHttpContext();
ctx.Request.Method = "POST";
ctx.Request.Path = "/api/echo";
ctx.Request.RouteValues["methodName"] = "echo";
ctx.Request.ContentLength = 0;
ctx.Connection.RemoteIpAddress = IPAddress.Parse("10.0.0.5");
var sentinel = new MemoryStream();
ctx.Request.Body = sentinel;
Stream? observedDuringHandler = null;
var mw = CreateMiddleware(hc =>
{
observedDuringHandler = hc.Request.Body;
hc.Response.StatusCode = 200;
return Task.CompletedTask;
}, writer);
await mw.InvokeAsync(ctx);
Assert.Same(sentinel, observedDuringHandler);
var evt = Assert.Single(writer.Events);
Assert.Null(evt.RequestSummary);
}
[Fact]
public async Task PostWithBody_StillEnablesBuffering_AndCapturesRequestSummary()
{
// Regression: the bodyless short-circuit must NOT regress the existing
// body-capture contract for normal POSTs — we still need to buffer +
// capture the request body for the audit row.
var writer = new RecordingAuditWriter();
var requestJson = "{\"a\":42}";
var ctx = BuildContext(body: requestJson);
string? observedAfterMiddleware = null;
var mw = CreateMiddleware(async hc =>
{
using var reader = new StreamReader(hc.Request.Body);
observedAfterMiddleware = await reader.ReadToEndAsync();
hc.Response.StatusCode = 200;
}, writer);
await mw.InvokeAsync(ctx);
Assert.Equal(requestJson, observedAfterMiddleware);
var evt = Assert.Single(writer.Events);
Assert.Equal(requestJson, evt.RequestSummary);
}
}
@@ -896,11 +896,15 @@ public class ManagementActorTests : TestKit, IDisposable
[Fact]
public void QueryDeployments_UnfilteredForSiteScopedUser_DropsOutOfScopeRecords()
{
// Records for instances 1 (site 1, in scope) and 2 (site 2, out of scope).
_templateRepo.GetInstanceByIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new Instance("Pump1") { Id = 1, SiteId = 1 });
_templateRepo.GetInstanceByIdAsync(2, Arg.Any<CancellationToken>())
.Returns(new Instance("Pump2") { Id = 2, SiteId = 2 });
// ManagementService-023: the unfiltered branch now bulk-loads instances
// once via GetAllInstancesAsync (instead of N+1 GetInstanceByIdAsync per
// distinct InstanceId). Mock the bulk path accordingly.
_templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Instance>
{
new("Pump1") { Id = 1, SiteId = 1 },
new("Pump2") { Id = 2, SiteId = 2 },
});
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
deployRepo.GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Deployment.DeploymentRecord>
@@ -917,6 +921,43 @@ public class ManagementActorTests : TestKit, IDisposable
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Contains("deploy-1", response.JsonData);
Assert.DoesNotContain("deploy-2", response.JsonData);
// The per-instance lookup must NOT have been used for the unfiltered
// branch — that was the N+1 the bulk load replaced.
_templateRepo.DidNotReceiveWithAnyArgs().GetInstanceByIdAsync(default);
}
[Fact]
public void QueryDeployments_UnfilteredForSiteScopedUser_UsesBulkInstanceLoad_NotPerRecordLookup()
{
// ManagementService-023 regression pin: the unfiltered branch must issue
// GetAllInstancesAsync ONCE and never call GetInstanceByIdAsync, regardless
// of how many DeploymentRecords reference distinct InstanceIds. Before the
// fix, three distinct instance ids would have produced three per-instance
// lookups (textbook N+1).
_templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Instance>
{
new("Pump1") { Id = 1, SiteId = 1 },
new("Pump2") { Id = 2, SiteId = 2 },
new("Pump3") { Id = 3, SiteId = 1 },
});
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
deployRepo.GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Deployment.DeploymentRecord>
{
DeploymentRecordFor(1), DeploymentRecordFor(2), DeploymentRecordFor(3),
DeploymentRecordFor(1), DeploymentRecordFor(3) // duplicates: still no extra lookups
});
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "1" }, "Deployment");
actor.Tell(envelope);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
_templateRepo.Received(1).GetAllInstancesAsync(Arg.Any<CancellationToken>());
_templateRepo.DidNotReceiveWithAnyArgs().GetInstanceByIdAsync(default);
}
[Fact]
@@ -395,6 +395,63 @@ public class NotificationOutboxActorDispatchTests : TestKit
"PostStop did not cancel the in-flight delivery promptly.");
}
// ── NotificationOutbox-006: adapter dictionary cached for the actor's lifetime ──
[Fact]
public void Dispatch_ResolvesAdaptersOnce_AcrossMultipleSweeps()
{
// NotificationOutbox-006: adapter registration is static per process lifetime,
// so the NotificationType -> adapter lookup must be built ONCE for the actor's
// lifetime, not per dispatch sweep. The cache is paired with an actor-lifetime
// DI scope (see _adaptersScope) so scoped adapter instances are reused safely.
SetupSmtpRetryPolicy(maxRetries: 5, retryDelay: TimeSpan.FromMinutes(1));
// Isolated substitutes for this test — we replace the dispatcher's per-sweep
// INotificationOutboxRepository registration with a private counting factory,
// so we don't mutate the shared _outboxRepository field that other tests in
// this class configure differently.
var outboxRepository = Substitute.For<INotificationOutboxRepository>();
outboxRepository.GetDueAsync(Arg.Any<DateTimeOffset>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(_ => new[] { MakeNotification() });
// Counting factory: increments each time the DI container resolves an
// INotificationDeliveryAdapter. Pre-fix this would have ticked once per
// sweep; post-fix it ticks exactly once for the actor's lifetime.
var resolutionCount = 0;
var services = new ServiceCollection();
services.AddScoped(_ => outboxRepository);
services.AddScoped(_ => _notificationRepository);
services.AddScoped<INotificationDeliveryAdapter>(_ =>
{
Interlocked.Increment(ref resolutionCount);
return new StubAdapter(() => DeliveryOutcome.Success("ops@example.com"));
});
var sp = services.BuildServiceProvider();
var actor = Sys.ActorOf(Props.Create(() => new NotificationOutboxActor(
sp,
new NotificationOutboxOptions { DispatchInterval = TimeSpan.FromHours(1) },
new NoOpCentralAuditWriter(),
NullLogger<NotificationOutboxActor>.Instance)));
// Fire three sweeps end-to-end. Each waits on the previous via the
// in-flight guard, so the UpdateAsync count climbs monotonically.
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() => outboxRepository.Received(1).UpdateAsync(
Arg.Any<Notification>(), Arg.Any<CancellationToken>()));
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() => outboxRepository.Received(2).UpdateAsync(
Arg.Any<Notification>(), Arg.Any<CancellationToken>()));
actor.Tell(InternalMessages.DispatchTick.Instance);
AwaitAssert(() => outboxRepository.Received(3).UpdateAsync(
Arg.Any<Notification>(), Arg.Any<CancellationToken>()));
// The adapter resolution must have happened EXACTLY ONCE despite three
// dispatch sweeps. Pre-fix this would have been 3 (or more).
Assert.Equal(1, resolutionCount);
}
[Fact]
public void OverlappingTicks_WhileDispatchInFlight_DoNotClaimConcurrently()
{
@@ -338,4 +338,52 @@ public sealed class BundleImporterPreviewTests : IDisposable
i.Kind == ConflictKind.Blocker && i.Name == name);
}
}
[Fact]
public async Task PreviewAsync_multiple_templates_with_children_diffs_each_correctly()
{
// Transport-008 regression: PreviewAsync previously fetched each matching
// template's children via a per-name GetTemplateWithChildrenAsync call
// (N+1). The bulk variant returns every match in a single query — this
// test seeds three templates with distinct child collections and asserts
// the preview hydrates each one so the per-child diff sees the right
// attribute / alarm / script counts (i.e. the bulk fetch did not lose
// any child rows compared to the per-name fetch).
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
var pump = new Template("Pump") { Description = "p1" };
pump.Attributes.Add(new TemplateAttribute("Flow"));
pump.Scripts.Add(new TemplateScript("init", "return 1;"));
var valve = new Template("Valve") { Description = "v1" };
valve.Alarms.Add(new TemplateAlarm("HighPressure"));
var tank = new Template("Tank") { Description = "t1" };
tank.Attributes.Add(new TemplateAttribute("Level"));
tank.Attributes.Add(new TemplateAttribute("Temperature"));
ctx.Templates.AddRange(pump, valve, tank);
await ctx.SaveChangesAsync();
}
var bundleStream = await ExportTemplatesAsync();
var bytes = await StreamToBytes(bundleStream);
ImportPreview preview;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
preview = await importer.PreviewAsync(session.SessionId);
}
// Each template should be diff-classified (Identical, since the bundle
// is the literal projection of the target). Critically, the diff must
// succeed for ALL three — a bulk-fetch bug that silently drops rows
// would surface here as a missing item or a wrong (New) classification.
var pumpItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Pump");
var valveItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Valve");
var tankItem = Assert.Single(preview.Items, i => i.EntityType == "Template" && i.Name == "Tank");
Assert.Equal(ConflictKind.Identical, pumpItem.Kind);
Assert.Equal(ConflictKind.Identical, valveItem.Kind);
Assert.Equal(ConflictKind.Identical, tankItem.Kind);
}
}