fix(external-system-gateway): resolve ExternalSystemGateway-011 — name-keyed repository lookups replace fetch-all-then-filter on the call hot path

This commit is contained in:
Joseph Doherty
2026-05-17 00:02:45 -04:00
parent 1e2e7d2e7c
commit a55502254e
10 changed files with 448 additions and 131 deletions

View File

@@ -8,7 +8,7 @@
| Last reviewed | 2026-05-16 |
| Reviewer | claude-agent |
| Commit reviewed | `9c60592` |
| Open findings | 1 |
| Open findings | 0 |
## Summary
@@ -533,7 +533,7 @@ exception propagates; it was verified to fail before the `try/catch` was added.
|--|--|
| Severity | Low |
| Category | Performance & resource management |
| Status | Open |
| Status | Resolved |
| Location | `src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs:360-374`, `src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs:169-176` |
**Description**
@@ -554,26 +554,67 @@ rather than fetch-all-then-filter.
**Resolution**
2026-05-16 — **Root cause confirmed, but left Open: no correct fix is possible within
this module's edit scope.** `ResolveSystemAndMethodAsync`
(`ExternalSystemClient.cs:360`) does call `GetAllExternalSystemsAsync()` followed by
`GetMethodsByExternalSystemIdAsync()` and filters in memory, and
`ResolveConnectionAsync` (`DatabaseGateway.cs:169`) does `GetAllDatabaseConnectionsAsync()`
then filters — fetch-all-then-filter on every hot-path call, as described.
Resolved 2026-05-16 (commit `<pending>`). A cross-module change was explicitly
authorized, so the **name-keyed repository lookup** recommendation was applied — the
cleaner of the two options, and one that avoids the staleness hazard a
deployment-invalidated cache would introduce.
Both recommended fixes require changes outside `src/ScadaLink.ExternalSystemGateway`:
(a) a **name-keyed repository lookup** (e.g. `GetExternalSystemByNameAsync`) means adding
methods to `IExternalSystemRepository` in `ScadaLink.Commons` and implementing them in
`ScadaLink.ConfigurationDatabase` / `ScadaLink.SiteRuntime`; (b) an **in-memory cache
invalidated on artifact deployment** requires subscribing to a deployment-applied event
owned by `ScadaLink.SiteRuntime` / `ScadaLink.DeploymentManager`. A purely module-local
cache with a time-based TTL was rejected as a fix: definitions only change on deployment
and must reflect a deployment promptly, so a TTL would either be too short to help the
hot path or long enough to serve stale definitions after a redeploy — trading a
correctness hazard for a performance gain on a Low-severity issue. **Tracked follow-up:**
add a name-keyed lookup to `IExternalSystemRepository` (Commons) and have the gateway use
it, or add a deployment-invalidated definition cache wired from SiteRuntime. No source
change was made in this module.
Three name-keyed methods were added to `IExternalSystemRepository`
(`ScadaLink.Commons`): `GetExternalSystemByNameAsync(name)`,
`GetMethodByNameAsync(externalSystemId, methodName)` and
`GetDatabaseConnectionByNameAsync(name)`. The connection lookup belongs on the same
interface because database connection definitions are already part of
`IExternalSystemRepository` (alongside `GetAllDatabaseConnectionsAsync` /
`GetDatabaseConnectionByIdAsync`), so the existing repository organization was
followed rather than introducing a new interface.
Both implementers of the interface were updated:
- `ScadaLink.ConfigurationDatabase.ExternalSystemRepository` — all three are genuine
server-side keyed queries (`FirstOrDefaultAsync(x => x.Name == name)`, the
method lookup additionally scoped by `ExternalSystemDefinitionId`), matching the
existing `GetMethodByNameAsync` / `GetListByNameAsync` / `GetSharedScriptByNameAsync`
convention in the other Central repositories.
- `ScadaLink.SiteRuntime.SiteExternalSystemRepository``GetExternalSystemByNameAsync`
and `GetDatabaseConnectionByNameAsync` are genuine single-row indexed SQLite queries
(`WHERE name = @name`; both tables have `name` as the PRIMARY KEY).
`GetMethodByNameAsync` resolves the named method from the parent system's
`method_definitions` JSON column; this still requires resolving the parent system
(one id→name scan), but the gateway's new call sequence performs **one** scan instead
of the previous **two** (`GetAllExternalSystemsAsync` + `GetMethodsByExternalSystemIdAsync`,
which itself scanned), so the site path is strictly better than before — noted as
the one place a residual scan remains, bounded by the deployed system count.
`ExternalSystemClient.ResolveSystemAndMethodAsync` and
`DatabaseGateway.ResolveConnectionAsync` were rewritten to call the keyed lookups; the
`GetAllExternalSystemsAsync` / `GetMethodsByExternalSystemIdAsync` /
`GetAllDatabaseConnectionsAsync` + in-memory `FirstOrDefault` filtering is gone from
both hot paths.
Regression tests (TDD — written first, verified failing/not-compiling before the
implementation, then confirmed green; one was additionally verified to fail when the
keyed query is deliberately broken):
- ConfigurationDatabase (`RepositoryCoverageTests`):
`GetExternalSystemByName_ReturnsMatchingRow`,
`GetExternalSystemByName_MissingName_ReturnsNull`,
`GetMethodByName_ReturnsMethodScopedToParentSystem`,
`GetMethodByName_MissingMethod_ReturnsNull`,
`GetDatabaseConnectionByName_ReturnsMatchingRow`,
`GetDatabaseConnectionByName_MissingName_ReturnsNull`.
- SiteRuntime (`SiteRepositoryTests`):
`ExternalSystemRepository_GetByName_ReturnsMatchingDefinition`,
`ExternalSystemRepository_GetByName_MissingName_ReturnsNull`,
`ExternalSystemRepository_GetMethodByName_ResolvesScopedToSystem`,
`DatabaseConnectionRepository_GetByName_ReturnsMatchingDefinition`.
- ExternalSystemGateway: the existing `ExternalSystemClientTests` /
`DatabaseGatewayTests` resolution stubs were migrated to the keyed methods (via
`StubResolution` / `StubConnection` helpers), so the full gateway suite now exercises
and protects the keyed-lookup resolution path.
`dotnet build ScadaLink.slnx` is clean; `ScadaLink.ExternalSystemGateway.Tests` (54),
`ScadaLink.ConfigurationDatabase.Tests` (106), `ScadaLink.SiteRuntime.Tests` (196) and
`ScadaLink.Commons.Tests` (226) all pass.
### ExternalSystemGateway-012 — Permanent-failure logging requirement is not met; `_logger` is injected but unused