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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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] =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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**
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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<Func<ISmtpClientWrapper>></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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user