perf(deploy): flatten-session caching, bulk DeploySiteAsync, paged management queries

This commit is contained in:
Joseph Doherty
2026-08-14 21:14:22 -04:00
parent ee193cd2bb
commit 48b3c40a7f
59 changed files with 3051 additions and 231 deletions
@@ -137,6 +137,57 @@ When deploying artifacts (shared scripts, external system definitions, etc.) to
- Deployment is performed at the **individual instance level**.
- The UI may provide convenience operations (e.g., "deploy all out-of-date instances at Site A"), but these decompose into individual instance deployments.
### Bulk site deployment (`DeploySiteAsync`, WP2.5)
`DeploySiteAsync(siteId, user)` deploys every instance at one site in a single
operation, surfaced as the `MgmtDeploySite` management command and the CLI
`deploy site --site-id`. It is the "deploy all at Site A" convenience above, made
first-class — it still decomposes into individual instance deployments, and each
instance keeps its own deployment id, revision hash, operation lock, and
optimistically-concurrent status record.
It runs the ordinary deployment pipeline in three phases, of which only the middle
one is parallel:
1. **Prepare (serial).** Validate transition, take the operation lock, flatten +
validate, run query-before-redeploy reconciliation, stage the
`PendingDeployment`, insert the `InProgress` record. Every step here touches the
scoped, non-thread-safe `DbContext`, so the phase is strictly serial. All
instances share ONE `FlattenSession`, so a template chain common to N instances
is walked once and the session-global queries (shared scripts, schema library,
the site's data connections) run once for the batch.
2. **Send (bounded parallel).** The `RefreshDeploymentCommand` round-trips run
concurrently up to `SiteDeploymentMaxParallelism` (default 4), each under a
`SiteDeploymentTimeoutPerInstance` deadline (default 120 s). This phase touches
no repository — that is exactly why it is the only phase allowed to run in
parallel. Shape mirrors `ArtifactDeploymentService.DeployCoreAsync`.
3. **Finalize (serial).** Commit terminal statuses, apply post-success side
effects, write audit rows, release each operation lock.
**Not all-or-nothing.** An instance that fails for any reason (wrong state, failed
validation, lock already held by another operation, site round-trip timed out) is
reported as a failed row while the rest proceed, and is individually retryable via
the ordinary single-instance deploy. This matches the artifact-deployment policy:
successful targets are never rolled back because another target failed.
`DeployInstanceAsync` is composed from the same three phase helpers with a batch of
one, so the two entry points cannot drift on deployment identity, idempotency, lock
coverage, or optimistic concurrency.
### Terminal deployment-record retention
Deployment records are insert-only — one row per deploy attempt — so without a
window the table grows for the life of the system and every unfiltered deployment
query degrades with age rather than with page size.
`TerminalDeploymentRecordRetention` (default **365 days**, deliberately generous
because deployment history is operator forensics) bounds it, swept opportunistically
and rate-limited on the deployment-list read path.
Only **terminal** rows (`Success` / `Failed`) are eligible. An `InProgress` row is
never purged regardless of age: it is precisely the row the query-before-redeploy
reconciliation reads to decide whether a prior deploy actually landed at the site,
and expiring it by age would silently disable that idempotency guard.
## Diff View
Before deploying, the Deployment Manager can request a diff from the Template Engine showing:
@@ -87,7 +87,7 @@ Both endpoints honour any site-scope rules attached to the caller's audit role b
### Templates
- **ListTemplates** / **GetTemplate**: Query template definitions. `ListTemplates` supports additive offset paging via `Skip` / `Take` (arch-review P2): `Take = null` (the default) preserves the historical unpaged behaviour (unlimited), a set `Take` is clamped to `1..1000` and `Skip` floors at `0`.
- **ListTemplates** / **GetTemplate**: Query template definitions. `ListTemplates` supports additive offset paging via `Skip` / `Take` (arch-review P2): `Take = null` (the default) preserves the historical unpaged behaviour (unlimited), a set `Take` is clamped to `1..1000` and `Skip` floors at `0`. **Paging and projection are DB-side (WP2.5):** the handler reads `TemplateSummary` rows (child collections reduced to counts, no script bodies) via `GetTemplateSummariesAsync`, instead of materialising every template's full five-Include child graph and then discarding all but one page in memory. A caller that needs a template's members already fetches it with `GetTemplate`.
- **CreateTemplate** / **UpdateTemplate** / **DeleteTemplate**: Manage templates.
- **ValidateTemplate**: Run on-demand pre-deployment validation (flattening, naming collisions, script compilation).
- **GetTemplateDiff**: Compare deployed vs. template-derived configuration for an instance.
@@ -115,9 +115,10 @@ The whole folder surface is reachable from the CLI as `template folder list|crea
### Instances
- **ListInstances** / **GetInstance**: Query instances, with filtering by site and area. `ListInstances` supports additive offset paging via `Skip` / `Take` (arch-review P2), applied **after** the in-memory site-scope filter so a site-scoped user never sees an out-of-scope instance surface into their page window; same `Take = null` unlimited default and `1..1000` clamp as `ListTemplates`. (`QueryDeployments` and `ExportBundle` still load the full table internally — a logged scale follow-up, arch-review P2 deferred.)
- **ListInstances** / **GetInstance**: Query instances, with filtering by site and area. `ListInstances` supports additive offset paging via `Skip` / `Take` (arch-review P2), applied **after** the in-memory site-scope filter so a site-scoped user never sees an out-of-scope instance surface into their page window; same `Take = null` unlimited default and `1..1000` clamp as `ListTemplates`. (`ExportBundle` still loads the full table internally — a logged scale follow-up, arch-review P2 deferred. `QueryDeployments` was closed by WP2.5, below.)
- **CreateInstance**: Create a new instance from a template.
- **UpdateInstanceOverrides**: Set attribute overrides on an instance.
- **UpdateInstanceOverrides** (`SetInstanceOverrides`): Set attribute overrides on an instance. Every requested attribute is validated against the instance's template up front (unknown or locked ⇒ the whole batch is rejected before any write), then the batch is applied as **one bulk read of the existing override rows plus one commit** (WP2.5). It previously ran K independent read-modify-commit cycles — each re-reading the instance, the template's attributes and the existing overrides, then committing and writing its own audit row — so the all-or-nothing promise was only as good as the pre-validation. It is now a real single-transaction apply, with one audit row summarising the batch.
- **MgmtDeploySite** (`SiteId`, WP2.5): Bulk-deploy every deployable instance at one site. Site scope is enforced on the SITE (the command's target); every instance the batch touches belongs to that site by construction. Same `Deployer` authority as `MgmtDeployInstance` — it is N of those, not a new class of privilege — and registered as a long-running command so the caller does not time out mid-batch. Semantics, phasing and failure policy: see `Component-DeploymentManager.md` → Bulk site deployment.
- **SetInstanceAlarmOverride** / **DeleteInstanceAlarmOverride** / **ListInstanceAlarmOverrides**: Manage per-instance computed-alarm overrides.
- **SetInstanceNativeAlarmSourceOverride** / **DeleteInstanceNativeAlarmSourceOverride**: Retarget or clear a per-instance native alarm source binding, keyed by `SourceCanonicalName``ConnectionNameOverride` / `SourceReferenceOverride` / `ConditionFilterOverride` each apply only when non-null. Gated to the **Deployment** role.
- **ListInstanceNativeAlarmSourceOverrides** (`InstanceId`): List an instance's native alarm source overrides (read-only).
@@ -144,7 +145,9 @@ The whole folder surface is reachable from the CLI as `template folder list|crea
- **DeployInstance**: Deploy configuration to a specific instance (includes pre-deployment validation).
- **DeployArtifacts**: Deploy system-wide artifacts (shared scripts, external system definitions, DB connections, data connections) to all sites or a specific site. The command's `SiteId` is honored (arch-review C2): a value routes to the single-site deploy path (`ArtifactDeploymentService.DeployToSiteAsync`), while `null` deploys fleet-wide (`DeployToAllSitesAsync`). Site scope is enforced — a site-scoped (non-Administrator) Deployer may only target a site within its `PermittedSiteIds`, and may **not** deploy fleet-wide (`SiteId is null` is rejected with `SiteScopeViolationException``ManagementUnauthorized`, since `EnforceSiteScope(null)` is a deliberate no-op); fleet-wide deployment requires a system-wide Deployer or Administrator.
- **DeploySite** (`MgmtDeploySite`, WP2.5): Bulk-deploy every deployable instance at one site — see the Instances section above and `Component-DeploymentManager.md`.
- **GetDeploymentStatus**: Query deployment status.
- **QueryDeployments** (`InstanceId?`, `Status?`, `Page`, `PageSize`): List deployment records. **All four arguments are honoured DB-side (WP2.5)** and the result is a `DeploymentRecordSummary` projection. The handler previously ignored `Status`, `Page` and `PageSize` entirely (the CLI had been sending them all along) and loaded the whole insert-only `DeploymentRecords` table; for a site-scoped user it additionally loaded every instance and intersected the two sets in memory. Site scope is now resolved to the set of in-scope instance ids and pushed into the query as an id filter — an **empty** scope stays a real filter (a user permitted no in-scope instances sees nothing), never a no-op. This read path also drives the opportunistic, rate-limited terminal-record retention sweep described in `Component-DeploymentManager.md`.
### Secured Writes (MxGateway, two-person)
@@ -259,7 +262,7 @@ The ManagementActor receives the following services and repositories via DI (inj
| Section | Options Class | Contents |
|---------|--------------|----------|
| `ScadaBridge:ManagementService` | `ManagementServiceOptions` | `CommandTimeout` (`TimeSpan`, default 30 s) — Ask timeout the HTTP endpoint applies when forwarding to the `ManagementActor`. A non-positive configured value falls back to the 30 s default. |
| | | `LongRunningCommandTimeout` (`TimeSpan`, default 5 min) — Ask timeout applied to long-running commands (`ImportBundle`, `PreviewBundle`, `ExportBundle`, `MgmtDeployArtifacts`, `MgmtDeployInstance`); all other commands use `CommandTimeout`. A non-positive configured value falls back to the 5 min default. |
| | | `LongRunningCommandTimeout` (`TimeSpan`, default 5 min) — Ask timeout applied to long-running commands (`ImportBundle`, `PreviewBundle`, `ExportBundle`, `MgmtDeployArtifacts`, `MgmtDeployInstance`, `MgmtDeploySite`); all other commands use `CommandTimeout`. A non-positive configured value falls back to the 5 min default. |
| | | `SecuredWritePendingTtl` (`TimeSpan`, default 24 h) — age after which a `Pending` secured write is transitioned to `Expired` and can no longer be approved/executed; enforced at approve/reject and swept opportunistically on list. A non-positive value disables expiry (arch-review S2). |
## Dependencies
@@ -158,6 +158,19 @@ Mirrors `TriggerExpressionGlobals` in the same way. Used by `ValidationService.C
`CheckExpressionSyntax` memoises its verdict in the Template Engine's process-wide `ScriptCompileVerdictCache`, whose key is the pair **(globals surface, SHA-256 of the code)** — not the code alone. The surface discriminator is load-bearing: a trigger expression vetted against `TriggerCompileSurface` is **not** interchangeable with a `ScriptCompileSurface` script-body verdict (different globals resolve different identifiers), so a code-only key could return a stale "clean" for code never compiled against the caller's surface. Keying by surface makes that cross-surface verdict reuse structurally impossible.
**Eviction is segmented, never wholesale (WP2.5).** The cache is bounded at two
generations of 2048 entries. New verdicts land in the *hot* generation; a hit in
*cold* is promoted back into hot; on overflow hot becomes the new cold and only the
old cold — the half nothing has touched for a full generation — is dropped.
The bound must never be enforced by clearing the cache outright. That reads as
harmless ("a verdict is cheap to recompute") but is not: a recompute is a fresh
Roslyn compile, and every script compile loads an assembly through a
**non-collectible `InteractiveAssemblyLoader`**. Bounding that leak is the entire
reason this cache exists, so dropping every hot entry at the 4096th distinct script
would re-open it for the whole working set at once. Segmenting retains anything in
active use across an eviction while keeping the same ceiling.
#### Parity guard
A reflection-based parity test in `SiteRuntime.Tests` compares the public member names on `ScriptCompileSurface` against `ScriptGlobals` (and `TriggerCompileSurface` against `TriggerExpressionGlobals`). Any drift between the stub and the real globals causes this test to fail, ensuring the stubs cannot silently fall out of sync.
@@ -137,6 +137,64 @@ When an instance is deployed, the Template Engine resolves the full configuratio
5. Resolve data connection bindings — replace connection name references with concrete connection details from the site.
6. Output a flat structure: list of attributes with resolved values and data source addresses, list of alarms with resolved trigger definitions, list of scripts with resolved code and triggers.
### Flatten-session caching and the graph watermark (WP2.5)
The flatten above is driven per instance, so an unmemoised implementation re-walks
the same template chain — one query per link, plus the compositions of every
template it reaches and of every composed chain those reach — once per instance,
and re-issues the three session-global queries (shared scripts, the shared-schema
library, the target site's data connections) each time too.
A **`FlattenSession`** memoises all of that for the lifetime of ONE
flatten/validate operation. Callers that flatten a batch (`DeploySiteAsync`) pass
one shared session; a caller that passes none gets a private single-use session,
which still collapses the repeated composed-chain loads inside a single instance's
flatten.
Cache validity is decided by **`ITemplateGraphWatermark`**, a process-wide set of
monotonic counters bumped by the configuration-database unit of work — the only
place every template-graph writer funnels through (`TemplateService`, the
`ManagementActor` native-alarm-source handlers, and the Transport bundle importer
all commit via the same `SaveChangesAsync`, and the change tracker is inspected
pre-commit to attribute each change to its owning template or instance):
- a memoised **template** is keyed on `(id, template version)`;
- a memoised **chain** is valid only while BOTH the graph's `StructureVersion`
(bumped on add/remove, re-parent, or any composition change — i.e. anything that
can alter chain MEMBERSHIP) and every member's own version are unchanged. Both
halves are load-bearing: structure alone misses an ordinary member edit, member
versions alone miss a re-parent.
Sessions are short-lived by construction, so the design's "template state is
captured at the time of flatten" guarantee is unchanged — the session narrows the
capture window, it never widens it.
The watermark is **in-memory and process-local**, deliberately not persisted and
not replicated between central nodes. A restart, a failover, or any mutation simply
misses and falls back to the authoritative full flatten, so the watermark can only
ever cause EXTRA work, never stale work.
The same watermark backs a **staleness fast path** in `StaleInstanceProbe`: a
previously computed revision hash is reused when the instance's version, the
structure version, and the version of every template that flatten walked are all
unchanged. This matters most where the probe is called per-instance across a whole
bundle import or a fleet-wide staleness sweep.
### Design-time analysis reads
The design-time checks — acyclicity (`CycleDetector`), naming collisions
(`CollisionDetector`) and canonical-name resolution (`TemplateResolver`) — read the
whole template graph but write through none of it and read no script bodies. They
use `GetAllTemplatesForAnalysisAsync`: `AsNoTracking`, with `TemplateScript.Code`
projected away.
The tracked, body-bearing `GetAllTemplatesAsync` remains the read for the two paths
that genuinely need it — the inheritance reconciler (which compares and copies
script bodies and writes through the loaded entities) and the flattener (which must
observe rows an in-flight bundle import has staged on the shared change tracker).
`ReconcileDescendantsAsync` additionally accepts an already-loaded tracked graph so
a caller holding one does not force a second load.
### Native Alarm Source Resolution
The `FlatteningService` resolves native alarm sources alongside alarms, emitting a `ResolvedNativeAlarmSource` (CanonicalName, ConnectionName, SourceReference, ConditionFilter *(optional)*, and `Source``Template` | `Inherited` | `Composed` | `Override`) for each. The resolved set is attached to `FlattenedConfiguration.NativeAlarmSources`.
@@ -19,6 +19,7 @@ public static class DeployCommands
var command = new Command("deploy") { Description = "Deployment operations" };
command.Add(BuildInstance(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildSite(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildArtifacts(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildStatus(urlOption, formatOption, usernameOption, passwordOption));
@@ -39,6 +40,43 @@ public static class DeployCommands
return cmd;
}
/// <summary>
/// Builds <c>deploy site</c> — bulk-deploy every instance at one site.
///
/// <para>
/// Sits under <c>deploy</c> alongside <c>deploy instance</c> and
/// <c>deploy artifacts</c>, naming the SCOPE of the deploy as the verb, which
/// is the convention the group already follows. Note it is deliberately NOT
/// fleet-wide when <c>--site-id</c> is omitted (the pattern <c>deploy
/// artifacts</c> uses): a bulk instance deploy is far more consequential than
/// an artifact push, so the target site is required rather than defaulted.
/// </para>
/// </summary>
private static Command BuildSite(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var siteIdOption = new Option<int>("--site-id") { Description = "Target site ID", Required = true };
var cmd = new Command("site") { Description = "Deploy every deployable instance at a site" };
cmd.Add(siteIdOption);
cmd.SetAction(async (ParseResult result) =>
{
var siteId = result.GetValue(siteIdOption);
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption,
new MgmtDeploySiteCommand(siteId),
// A bulk deploy is N instance round-trips; the default 30 s client
// timeout would abandon the request while the server is still
// applying. Matches the server-side long-running Ask window.
timeout: BulkDeployTimeout);
});
return cmd;
}
/// <summary>
/// Client-side timeout for the bulk site deploy, matching the management
/// service's long-running command Ask window.
/// </summary>
internal static readonly TimeSpan BulkDeployTimeout = TimeSpan.FromMinutes(5);
private static Command BuildArtifacts(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var siteIdOption = new Option<int?>("--site-id") { Description = "Target site ID (all sites if omitted)" };
+31
View File
@@ -952,6 +952,37 @@ scadabridge --url <url> deploy instance --id <int>
|--------|----------|-------------|
| `--id` | yes | Instance ID |
#### `deploy site`
Deploy **every deployable instance** at one site in a single batched operation.
Each instance keeps the full single-instance semantics — its own deployment ID and
revision hash, its own per-instance operation lock, its own optimistically-concurrent
status record, and the same query-before-redeploy idempotency check. The batch is
**not** all-or-nothing: an instance that cannot be deployed (wrong state, failed
validation, lock already held, site round-trip timed out) is reported as a failed row
and the rest proceed. Each failed instance is individually retryable with
`instance deploy --id`.
Site round-trips run with bounded concurrency
(`ScadaBridge:DeploymentManager:SiteDeploymentMaxParallelism`, default 4), each under
`SiteDeploymentTimeoutPerInstance` (default 120 s), so one wedged instance cannot stall
the batch. The client waits up to 5 minutes.
`--site-id` is **required** — unlike `deploy artifacts`, omitting it does not mean
fleet-wide.
```sh
scadabridge --url <url> deploy site --site-id <int>
```
| Option | Required | Description |
|--------|----------|-------------|
| `--site-id` | yes | Target site ID. A site-scoped (non-Administrator) Deployer must supply an in-scope site. |
Output is a per-instance result matrix (`instanceId`, `uniqueName`, `deploymentId`,
`success`, `errorMessage`) plus `successCount` / `failureCount`.
#### `deploy artifacts`
Deploy compiled artifacts to one or all sites (same as `site deploy-artifacts`).
@@ -22,6 +22,48 @@ public interface IDeploymentManagerRepository
/// <returns>A read-only list of all deployment records.</returns>
Task<IReadOnlyList<DeploymentRecord>> GetAllDeploymentRecordsAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Database-side paged and filtered deployment query returning row summaries.
/// Backs the <c>QueryDeployments</c> management command, which previously
/// loaded the ENTIRE deployment-record table (insert-only, one row per deploy
/// attempt for the life of the system) and ignored the page arguments the CLI
/// was already sending.
/// </summary>
/// <param name="instanceId">Restrict to one instance's history, or <see langword="null"/> for all.</param>
/// <param name="status">Restrict to one deployment status, or <see langword="null"/> for all.</param>
/// <param name="instanceIdScope">
/// When non-null, restrict to these instance ids. Used to apply a site-scoped
/// user's permitted-site filter IN THE DATABASE rather than by loading every
/// record and every instance and filtering in memory.
/// </param>
/// <param name="skip">Rows to skip; negative values are floored at 0.</param>
/// <param name="take">Page size; non-positive or null returns every remaining row.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>One page of deployment summaries, newest first.</returns>
Task<IReadOnlyList<Types.Deployment.DeploymentRecordSummary>> QueryDeploymentSummariesAsync(
int? instanceId,
DeploymentStatus? status,
IReadOnlyCollection<int>? instanceIdScope,
int skip,
int? take,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes TERMINAL deployment records (<see cref="DeploymentStatus.Success"/> /
/// <see cref="DeploymentStatus.Failed"/>) that completed before
/// <paramref name="cutoffUtc"/>, in one bounded batch.
///
/// <para>
/// Non-terminal rows are never touched: an <c>InProgress</c> record is what the
/// query-before-redeploy reconciliation path reads to decide whether a prior
/// deploy actually landed at the site, so purging one by age would silently
/// disable that idempotency guard.
/// </para>
/// </summary>
/// <param name="cutoffUtc">Records completing before this instant are eligible.</param>
/// <param name="batchSize">Maximum rows deleted in this call.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>The number of rows deleted; fewer than <paramref name="batchSize"/> means the sweep is complete.</returns>
Task<int> PurgeTerminalDeploymentRecordsAsync(DateTimeOffset cutoffUtc, int batchSize, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all deployment records for a specific instance.
/// </summary>
/// <param name="instanceId">The instance ID.</param>
@@ -35,6 +35,41 @@ public interface ITemplateEngineRepository
/// <returns>A task that resolves to a read-only list of all templates.</returns>
Task<IReadOnlyList<Template>> GetAllTemplatesAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Read-only, no-tracking variant of <see cref="GetAllTemplatesAsync"/> for the
/// design-time ANALYSIS walks — acyclicity (<c>CycleDetector</c>), naming
/// collisions (<c>CollisionDetector</c>) and canonical-name resolution
/// (<c>TemplateResolver</c>). Script bodies are NOT loaded
/// (<see cref="TemplateScript.Code"/> comes back empty) because no analysis
/// consumer reads them; everything those walks do read — ids, parent and
/// composition edges, member names and lock flags — is present.
///
/// <para>
/// Do NOT use this for the inheritance reconciler or the flattener: the
/// reconciler compares and copies script bodies and writes through tracked
/// entities, and the flattener must observe rows an in-flight bundle import
/// has staged on the shared change tracker. Both keep using
/// <see cref="GetAllTemplatesAsync"/> / <see cref="GetTemplateWithChildrenAsync"/>.
/// </para>
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that resolves to a read-only list of all templates without script bodies.</returns>
Task<IReadOnlyList<Template>> GetAllTemplatesForAnalysisAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Database-side paged listing of templates as row summaries (child
/// collections reduced to counts, no script bodies). Backs the
/// <c>ListTemplates</c> management command, which previously materialised
/// every template's full child graph and then paged the list in memory.
/// </summary>
/// <param name="skip">Number of rows to skip; negative values are floored at 0.</param>
/// <param name="take">
/// Page size. <see langword="null"/> or non-positive returns every remaining
/// row (the historical unpaged behaviour); otherwise clamped to
/// <see cref="Types.Templates.TemplateSummary.MaxPageSize"/>.
/// </param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that resolves to one page of template summaries, ordered by id.</returns>
Task<IReadOnlyList<Types.Templates.TemplateSummary>> GetTemplateSummariesAsync(int skip, int? take, CancellationToken cancellationToken = default);
/// <summary>
/// Returns every template that contains a composition referencing
/// <paramref name="composedTemplateId"/>. Each result is eager-loaded with
/// its Attributes / Scripts / Compositions so the caller can build a
@@ -0,0 +1,82 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
/// <summary>
/// Process-wide monotonic version watermark over the template/instance
/// configuration graph.
///
/// <para>
/// Every mutation that lands through the configuration database bumps the
/// version of the affected template (or instance), plus a global counter and —
/// for edge-shaped changes (template added/removed, parent or composition edge
/// changed) — a separate <see cref="StructureVersion"/>. Consumers use the
/// watermark two ways:
/// </para>
/// <list type="bullet">
/// <item>
/// as the discriminator in a flatten-session cache key
/// (<c>templateId + version</c>), so a memoised template chain can never be
/// served after that template changed; and
/// </item>
/// <item>
/// as a staleness fast path — when neither the instance nor any template in
/// its chain has moved since a previously computed revision hash, the hash
/// is still current and a full re-flatten can be skipped.
/// </item>
/// </list>
///
/// <para>
/// Versions are in-memory and process-local. They are deliberately NOT
/// persisted: a restart (or the standby central node) simply starts from zero,
/// which invalidates every cached entry and falls back to the authoritative
/// full flatten. The watermark is therefore only ever able to cause EXTRA work,
/// never stale work — a correctness property that matters because the value is
/// not replicated between the two central nodes.
/// </para>
/// </summary>
public interface ITemplateGraphWatermark
{
/// <summary>
/// Monotonic counter bumped on every template- or instance-graph mutation.
/// Useful as a coarse "did anything change at all" gate.
/// </summary>
long Global { get; }
/// <summary>
/// Monotonic counter bumped only when the SHAPE of the graph changes — a
/// template row added or removed, a parent-template edge changed, or a
/// composition row added/removed/repointed. A cached template-chain
/// MEMBERSHIP is valid only while this value is unchanged; the per-template
/// versions then cover the contents of each member.
/// </summary>
long StructureVersion { get; }
/// <summary>Current version of a single template. Zero when never mutated in this process.</summary>
/// <param name="templateId">Template id to read.</param>
/// <returns>The template's monotonic version.</returns>
long GetTemplateVersion(int templateId);
/// <summary>Current version of a single instance (its override/binding rows). Zero when never mutated in this process.</summary>
/// <param name="instanceId">Instance id to read.</param>
/// <returns>The instance's monotonic version.</returns>
long GetInstanceVersion(int instanceId);
/// <summary>Records a mutation of the given template.</summary>
/// <param name="templateId">Template whose version is bumped.</param>
/// <param name="structural">
/// When <c>true</c> the change altered the graph shape (add/remove/parent or
/// composition edge) and <see cref="StructureVersion"/> is bumped too.
/// </param>
void BumpTemplate(int templateId, bool structural = false);
/// <summary>Records a mutation of the given instance.</summary>
/// <param name="instanceId">Instance whose version is bumped.</param>
void BumpInstance(int instanceId);
/// <summary>
/// Records a mutation whose affected template/instance could not be
/// attributed to a specific id. Bumps <see cref="Global"/> and
/// <see cref="StructureVersion"/>, invalidating every chain-membership and
/// staleness fast path — the safe, conservative fallback.
/// </summary>
void BumpAll();
}
@@ -1,5 +1,26 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
public record MgmtDeployArtifactsCommand(int? SiteId = null);
/// <summary>
/// Deploys every deployable instance at one site in a single operation.
///
/// <para>
/// Distinct from <see cref="MgmtDeployArtifactsCommand"/>, which ships the
/// SYSTEM-WIDE artifact sets (shared scripts, external systems, DB/data
/// connections) to a site. This command deploys the site's INSTANCE
/// configurations — the same per-instance pipeline
/// <c>MgmtDeployInstanceCommand</c> runs, batched.
/// </para>
///
/// <para>
/// Not all-or-nothing: each instance keeps its own deployment id, revision hash,
/// operation lock and status record, and a failure on one is reported as a failed
/// row while the rest proceed. Individually retryable via the single-instance
/// deploy. Additive contract; new fields go on the end with a default.
/// </para>
/// </summary>
/// <param name="SiteId">Database id of the target site. Required — this command is never fleet-wide.</param>
public record MgmtDeploySiteCommand(int SiteId);
public record QueryDeploymentsCommand(int? InstanceId = null, string? Status = null, int Page = 1, int PageSize = 50);
public record GetDeploymentDiffCommand(int InstanceId);
@@ -0,0 +1,54 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
/// <summary>
/// Row-shaped projection of a <c>DeploymentRecord</c> for list surfaces (CLI
/// <c>deploy list</c> / <c>instance</c> history, the Central UI deployment-status
/// page).
///
/// <para>
/// Exists so a deployment listing can be paged and filtered in the DATABASE.
/// Deployment records are insert-only — one row per deploy attempt, retained for
/// the configured retention window — so the previous "load the whole table, then
/// filter in memory" read path degraded with the age of the system rather than
/// with the size of the requested page.
/// </para>
///
/// <para>
/// The <c>RowVersion</c> optimistic-concurrency token is deliberately absent: this
/// is a READ projection, and a summary must never be mistaken for something that
/// can be written back. Mutating paths keep loading the tracked entity.
/// </para>
///
/// <para>
/// Message-contract evolution rule: additive-only. New fields go on the end with a
/// default.
/// </para>
/// </summary>
/// <param name="Id">Deployment record row id.</param>
/// <param name="DeploymentId">The logical deployment id (GUID, "N" format).</param>
/// <param name="InstanceId">Instance the deployment targeted.</param>
/// <param name="Status">Terminal or in-flight deployment status.</param>
/// <param name="RevisionHash">Revision hash of the deployed configuration.</param>
/// <param name="DeployedBy">User who initiated the deployment.</param>
/// <param name="DeployedAt">When the deployment was initiated.</param>
/// <param name="CompletedAt">When the deployment reached a terminal status, if it has.</param>
/// <param name="ErrorMessage">Failure detail when the deployment did not succeed.</param>
public record DeploymentRecordSummary(
int Id,
string DeploymentId,
int InstanceId,
DeploymentStatus Status,
string? RevisionHash,
string DeployedBy,
DateTimeOffset DeployedAt,
DateTimeOffset? CompletedAt,
string? ErrorMessage)
{
/// <summary>
/// Hard ceiling applied to a requested page size, mirroring the in-memory
/// <c>Page</c> clamp the management actor already applies elsewhere.
/// </summary>
public const int MaxPageSize = 1000;
}
@@ -0,0 +1,72 @@
using System.Collections.Concurrent;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
/// <summary>
/// In-memory <see cref="ITemplateGraphWatermark"/>. Registered as a singleton so
/// the writer (the configuration-database repository's unit-of-work commit) and
/// the readers (the flatten-session cache, the staleness fast path) observe the
/// same counters within a process.
///
/// <para>
/// Lives in Commons rather than a component project because both the
/// ConfigurationDatabase writer and the DeploymentManager readers need it and
/// neither references the other. It is a pure in-memory primitive with no
/// infrastructure dependency, so it does not violate the "implementations live
/// with their component" convention the way a repository would.
/// </para>
///
/// <para>
/// All counters are <see cref="Interlocked"/>-updated and the per-id maps are
/// <see cref="ConcurrentDictionary{TKey,TValue}"/>, so concurrent management
/// commands (which each run on their own DI scope) can bump safely. Reads are
/// deliberately unsynchronised relative to each other: a reader that observes a
/// half-applied batch of bumps sees a CHANGED value for at least one member,
/// which invalidates rather than falsely validates a cache entry.
/// </para>
/// </summary>
public sealed class TemplateGraphWatermark : ITemplateGraphWatermark
{
private readonly ConcurrentDictionary<int, long> _templateVersions = new();
private readonly ConcurrentDictionary<int, long> _instanceVersions = new();
private long _global;
private long _structure;
/// <inheritdoc />
public long Global => Interlocked.Read(ref _global);
/// <inheritdoc />
public long StructureVersion => Interlocked.Read(ref _structure);
/// <inheritdoc />
public long GetTemplateVersion(int templateId) =>
_templateVersions.TryGetValue(templateId, out var v) ? v : 0L;
/// <inheritdoc />
public long GetInstanceVersion(int instanceId) =>
_instanceVersions.TryGetValue(instanceId, out var v) ? v : 0L;
/// <inheritdoc />
public void BumpTemplate(int templateId, bool structural = false)
{
_templateVersions.AddOrUpdate(templateId, 1L, static (_, current) => current + 1);
Interlocked.Increment(ref _global);
if (structural)
Interlocked.Increment(ref _structure);
}
/// <inheritdoc />
public void BumpInstance(int instanceId)
{
_instanceVersions.AddOrUpdate(instanceId, 1L, static (_, current) => current + 1);
Interlocked.Increment(ref _global);
}
/// <inheritdoc />
public void BumpAll()
{
Interlocked.Increment(ref _global);
Interlocked.Increment(ref _structure);
}
}
@@ -0,0 +1,53 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
/// <summary>
/// Row-shaped projection of a template for list surfaces (CLI <c>template list</c>,
/// the Central UI template tree, the <c>ListTemplates</c> management command).
///
/// <para>
/// Exists so a template listing can be paged in the DATABASE rather than by
/// materialising every template's full child graph — attributes, alarms, script
/// bodies, compositions and native alarm sources — and then discarding all but
/// one page of it in memory. Child collections are reduced to counts, which is
/// what every list surface actually renders.
/// </para>
///
/// <para>
/// Message-contract evolution rule: this record is additive-only. New fields go
/// on the end with a default so an older peer deserialising a newer payload is
/// unaffected.
/// </para>
/// </summary>
/// <param name="Id">Template id.</param>
/// <param name="Name">Template name.</param>
/// <param name="Description">Optional description.</param>
/// <param name="ParentTemplateId">Parent template id when this template inherits.</param>
/// <param name="FolderId">Containing folder id.</param>
/// <param name="IsDerived">True when the template was auto-derived to back a composition slot.</param>
/// <param name="OwnerCompositionId">Owning composition row for a derived template.</param>
/// <param name="AttributeCount">Number of directly declared attributes.</param>
/// <param name="AlarmCount">Number of directly declared alarms.</param>
/// <param name="ScriptCount">Number of directly declared scripts.</param>
/// <param name="CompositionCount">Number of composition slots.</param>
/// <param name="NativeAlarmSourceCount">Number of native alarm source bindings.</param>
public record TemplateSummary(
int Id,
string Name,
string? Description,
int? ParentTemplateId,
int? FolderId,
bool IsDerived,
int? OwnerCompositionId,
int AttributeCount,
int AlarmCount,
int ScriptCount,
int CompositionCount,
int NativeAlarmSourceCount)
{
/// <summary>
/// Hard ceiling applied to a requested page size, mirroring the existing
/// in-memory <c>Page</c> clamp in the management actor so DB-side paging
/// cannot be used to pull an unbounded result set.
/// </summary>
public const int MaxPageSize = 1000;
}
@@ -277,6 +277,89 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
return expired.Count;
}
/// <inheritdoc />
public async Task<IReadOnlyList<DeploymentRecordSummary>> QueryDeploymentSummariesAsync(
int? instanceId,
DeploymentStatus? status,
IReadOnlyCollection<int>? instanceIdScope,
int skip,
int? take,
CancellationToken cancellationToken = default)
{
var query = _dbContext.DeploymentRecords.AsNoTracking();
if (instanceId.HasValue)
query = query.Where(d => d.InstanceId == instanceId.Value);
if (status.HasValue)
query = query.Where(d => d.Status == status.Value);
if (instanceIdScope != null)
{
// Site scoping applied DB-side. An empty scope is a real, meaningful
// filter — a user permitted no in-scope instances sees nothing — so it
// must not be short-circuited into "no filter".
var scope = instanceIdScope as int[] ?? instanceIdScope.ToArray();
query = query.Where(d => scope.Contains(d.InstanceId));
}
// ThenByDescending(Id) for the same reason GetCurrentDeploymentStatusAsync
// does it: DeployedAt ties on rapid redeploys, and an unstable sort key
// makes Skip/Take paging non-deterministic (rows repeat or vanish between
// pages).
var paged = query
.OrderByDescending(d => d.DeployedAt)
.ThenByDescending(d => d.Id)
.Skip(Math.Max(0, skip));
if (take is > 0)
paged = paged.Take(Math.Min(take.Value, DeploymentRecordSummary.MaxPageSize));
return await paged
.Select(d => new DeploymentRecordSummary(
d.Id,
d.DeploymentId,
d.InstanceId,
d.Status,
d.RevisionHash,
d.DeployedBy,
d.DeployedAt,
d.CompletedAt,
d.ErrorMessage))
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<int> PurgeTerminalDeploymentRecordsAsync(
DateTimeOffset cutoffUtc,
int batchSize,
CancellationToken cancellationToken = default)
{
// Terminal statuses ONLY. An InProgress (or Pending) row is never purged
// regardless of age: it is precisely the row TryReconcileWithSiteAsync
// looks for when deciding whether a prior deploy actually landed, and
// deleting it would silently disable the query-before-redeploy idempotency
// guard for that instance.
//
// Bounded batch rather than one unbounded DELETE so the first purge on a
// long-lived system cannot take a table-scale lock on DeploymentRecords.
// The caller re-invokes until it returns fewer rows than the batch size.
var stale = await _dbContext.DeploymentRecords
.Where(d => d.CompletedAt != null
&& d.CompletedAt < cutoffUtc
&& (d.Status == DeploymentStatus.Success || d.Status == DeploymentStatus.Failed))
.OrderBy(d => d.Id)
.Take(batchSize)
.ToListAsync(cancellationToken);
if (stale.Count == 0)
return 0;
_dbContext.DeploymentRecords.RemoveRange(stale);
await _dbContext.SaveChangesAsync(cancellationToken);
return stale.Count;
}
// --- Startup reconciliation ---
/// <inheritdoc />
@@ -3,20 +3,32 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
public class TemplateEngineRepository : ITemplateEngineRepository
{
private readonly ScadaBridgeDbContext _context;
private readonly ITemplateGraphWatermark _watermark;
/// <summary>
/// Initializes a new instance of the TemplateEngineRepository class.
/// </summary>
/// <param name="context">The database context used to access template and instance data.</param>
public TemplateEngineRepository(ScadaBridgeDbContext context)
/// <param name="watermark">
/// Process-wide template/instance version watermark. This repository is the
/// single unit-of-work through which EVERY template-graph mutation commits —
/// <c>TemplateService</c>, the <c>ManagementActor</c> native-alarm-source
/// handlers, and the Transport bundle importer all funnel through the same
/// <see cref="SaveChangesAsync"/> — so bumping here (rather than in each
/// caller) is the only placement that cannot be bypassed.
/// </param>
public TemplateEngineRepository(ScadaBridgeDbContext context, ITemplateGraphWatermark watermark)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_watermark = watermark ?? throw new ArgumentNullException(nameof(watermark));
}
// Template
@@ -78,6 +90,95 @@ public class TemplateEngineRepository : ITemplateEngineRepository
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
/// <remarks>
/// Two deliberate departures from <see cref="GetAllTemplatesAsync"/>:
/// <list type="number">
/// <item>
/// <c>AsNoTracking</c> — the analysis walks (cycle detection, collision
/// detection, canonical-name resolution) never write through the loaded
/// graph, so paying for a change-tracker snapshot of every attribute,
/// alarm, script and composition in the database is pure waste.
/// </item>
/// <item>
/// <c>TemplateScript.Code</c> is projected away (left empty). Script
/// bodies are by far the largest column in the graph and NONE of the
/// analysis consumers read them — <c>CycleDetector</c> reads only ids and
/// edges, <c>CollisionDetector</c> and <c>TemplateResolver</c> read only
/// member names and lock flags.
/// </item>
/// </list>
/// Callers that DO need bodies or tracked entities — the inheritance
/// reconciler and the flattener — must keep using
/// <see cref="GetAllTemplatesAsync"/> / <see cref="GetTemplateWithChildrenAsync"/>.
/// </remarks>
public async Task<IReadOnlyList<Template>> GetAllTemplatesForAnalysisAsync(CancellationToken cancellationToken = default)
{
return await _context.Templates
.AsNoTracking()
.Select(t => new Template(t.Name)
{
Id = t.Id,
Description = t.Description,
ParentTemplateId = t.ParentTemplateId,
FolderId = t.FolderId,
IsDerived = t.IsDerived,
OwnerCompositionId = t.OwnerCompositionId,
Attributes = t.Attributes.ToList(),
Alarms = t.Alarms.ToList(),
NativeAlarmSources = t.NativeAlarmSources.ToList(),
Compositions = t.Compositions.ToList(),
Scripts = t.Scripts
.Select(s => new TemplateScript(s.Name, string.Empty)
{
Id = s.Id,
TemplateId = s.TemplateId,
IsLocked = s.IsLocked,
TriggerType = s.TriggerType,
TriggerConfiguration = s.TriggerConfiguration,
ParameterDefinitions = s.ParameterDefinitions,
ReturnDefinition = s.ReturnDefinition,
MinTimeBetweenRuns = s.MinTimeBetweenRuns,
ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds,
IsInherited = s.IsInherited,
LockedInDerived = s.LockedInDerived
})
.ToList()
})
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TemplateSummary>> GetTemplateSummariesAsync(
int skip,
int? take,
CancellationToken cancellationToken = default)
{
var query = _context.Templates
.AsNoTracking()
.OrderBy(t => t.Id)
.Skip(Math.Max(0, skip));
if (take is > 0)
query = query.Take(Math.Min(take.Value, TemplateSummary.MaxPageSize));
return await query
.Select(t => new TemplateSummary(
t.Id,
t.Name,
t.Description,
t.ParentTemplateId,
t.FolderId,
t.IsDerived,
t.OwnerCompositionId,
t.Attributes.Count,
t.Alarms.Count,
t.Scripts.Count,
t.Compositions.Count,
t.NativeAlarmSources.Count))
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Template>> GetTemplatesComposingAsync(int composedTemplateId, CancellationToken cancellationToken = default)
{
@@ -659,8 +760,129 @@ public class TemplateEngineRepository : ITemplateEngineRepository
}
/// <inheritdoc />
/// <remarks>
/// Inspects the change tracker BEFORE committing (afterwards every entry is
/// <see cref="EntityState.Unchanged"/> and the attribution is lost) and bumps
/// the <see cref="ITemplateGraphWatermark"/> for each affected template /
/// instance. The bump is applied only AFTER a successful commit, so a failed
/// save does not invalidate caches for a change that never landed.
/// </remarks>
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
return await _context.SaveChangesAsync(cancellationToken);
var pending = CollectWatermarkBumps();
var written = await _context.SaveChangesAsync(cancellationToken);
ApplyWatermarkBumps(pending);
return written;
}
/// <summary>
/// Snapshot of the watermark bumps implied by the current change-tracker
/// contents. Captured pre-commit, applied post-commit.
/// </summary>
private readonly record struct WatermarkBumps(
HashSet<int> Templates,
HashSet<int> StructuralTemplates,
HashSet<int> Instances,
bool Unattributed);
/// <summary>
/// Walks the change tracker and maps every added/modified/deleted
/// template-graph or instance-graph entry back to the template (or instance)
/// whose flattened output it can affect.
///
/// <para>
/// A change is treated as STRUCTURAL — invalidating cached chain membership,
/// not just chain contents — when it adds or removes a <see cref="Template"/>
/// row, changes a <see cref="Template.ParentTemplateId"/>, or touches a
/// <see cref="TemplateComposition"/> row. Those are exactly the edges the
/// flattener walks to build a chain.
/// </para>
///
/// <para>
/// An entry whose owning id cannot be resolved (a child row deleted before
/// its FK was materialised, or an entity type added later that this switch
/// does not know) sets <c>Unattributed</c>, which conservatively invalidates
/// every cached chain rather than silently letting a stale entry survive.
/// </para>
/// </summary>
private WatermarkBumps CollectWatermarkBumps()
{
var templates = new HashSet<int>();
var structural = new HashSet<int>();
var instances = new HashSet<int>();
var unattributed = false;
foreach (var entry in _context.ChangeTracker.Entries())
{
if (entry.State is not (EntityState.Added or EntityState.Modified or EntityState.Deleted))
continue;
switch (entry.Entity)
{
case Template t:
templates.Add(t.Id);
// Add/Delete changes chain membership; a re-parent changes it too.
if (entry.State != EntityState.Modified
|| entry.Property(nameof(Template.ParentTemplateId)).IsModified)
{
structural.Add(t.Id);
}
break;
case TemplateAttribute a:
templates.Add(a.TemplateId);
break;
case TemplateAlarm al:
templates.Add(al.TemplateId);
break;
case TemplateScript s:
templates.Add(s.TemplateId);
break;
case TemplateNativeAlarmSource ns:
templates.Add(ns.TemplateId);
break;
case TemplateComposition c:
templates.Add(c.TemplateId);
structural.Add(c.TemplateId);
break;
case Instance i:
instances.Add(i.Id);
break;
case InstanceAttributeOverride ao:
instances.Add(ao.InstanceId);
break;
case InstanceAlarmOverride alo:
instances.Add(alo.InstanceId);
break;
case InstanceNativeAlarmSourceOverride nso:
instances.Add(nso.InstanceId);
break;
case InstanceConnectionBinding cb:
instances.Add(cb.InstanceId);
break;
case SharedScript:
// Shared scripts are a session-global input to validation, not
// owned by any one template — invalidate everything.
unattributed = true;
break;
default:
// Areas, folders and anything else do not feed the flattener.
break;
}
}
return new WatermarkBumps(templates, structural, instances, unattributed);
}
/// <summary>Applies a previously collected <see cref="WatermarkBumps"/> snapshot.</summary>
private void ApplyWatermarkBumps(WatermarkBumps bumps)
{
if (bumps.Unattributed)
_watermark.BumpAll();
foreach (var templateId in bumps.Templates)
_watermark.BumpTemplate(templateId, bumps.StructuralTemplates.Contains(templateId));
foreach (var instanceId in bumps.Instances)
_watermark.BumpInstance(instanceId);
}
}
@@ -1,10 +1,12 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
@@ -49,6 +51,13 @@ public static class ServiceCollectionExtensions
return new ScadaBridgeDbContext(options, protectionProvider);
});
// Process-wide template/instance version watermark. Singleton because the
// writer (TemplateEngineRepository.SaveChangesAsync, scoped) and the readers
// (the flatten-session cache and the staleness fast path, also scoped) must
// observe the same counters across DI scopes. TryAdd so the DeploymentManager
// registration of the same interface is idempotent.
services.TryAddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
services.AddScoped<ISecurityRepository, SecurityRepository>();
services.AddScoped<ICentralUiRepository, CentralUiRepository>();
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
@@ -17,4 +17,43 @@ public class DeploymentManagerOptions
/// <summary>Timeout for acquiring an operation lock on an instance.</summary>
public TimeSpan OperationLockTimeout { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Maximum number of instance deployments whose SITE ROUND-TRIP runs
/// concurrently during a bulk <c>DeploySiteAsync</c>. The flatten/validate and
/// persistence phases stay serial regardless (they share one non-thread-safe
/// <c>DbContext</c>); only the network wait is fanned out.
///
/// <para>
/// Bounded rather than unbounded so a large site cannot open one in-flight
/// gRPC command per instance against a two-node site pair. Four mirrors the
/// conservative end of the site's own apply concurrency; raise it only with
/// evidence from the target-scale load test.
/// </para>
/// </summary>
public int SiteDeploymentMaxParallelism { get; set; } = 4;
/// <summary>
/// Per-instance deadline applied to the site round-trip inside a bulk
/// <c>DeploySiteAsync</c>. One unreachable or wedged instance is recorded as a
/// failed row and the rest of the batch continues — the bulk operation never
/// inherits a single instance's hang.
/// </summary>
public TimeSpan SiteDeploymentTimeoutPerInstance { get; set; } = TimeSpan.FromSeconds(120);
/// <summary>
/// Retention window for TERMINAL deployment records (<c>Success</c>,
/// <c>Failed</c>). Deployments are insert-only — one row per attempt, forever —
/// so without a window the table grows without bound and every unfiltered
/// deployment query gets slower with the age of the system.
///
/// <para>
/// Deliberately generous (one year) because deployment history is
/// operator-facing forensics, and deliberately terminal-only: an
/// <c>InProgress</c> or <c>Pending</c> row is never purged regardless of age,
/// because it is exactly the row the query-before-redeploy reconciliation
/// path needs to find. Set to <see cref="TimeSpan.Zero"/> to disable purging.
/// </para>
/// </summary>
public TimeSpan TerminalDeploymentRecordRetention { get; set; } = TimeSpan.FromDays(365);
}
@@ -27,5 +27,20 @@ public sealed class DeploymentManagerOptionsValidator : OptionsValidatorBase<Dep
builder.RequireThat(options.OperationLockTimeout > TimeSpan.Zero,
$"ScadaBridge:DeploymentManager:OperationLockTimeout must be a positive duration " +
$"(was {options.OperationLockTimeout}); it bounds acquiring an instance operation lock.");
builder.RequireThat(options.SiteDeploymentMaxParallelism > 0,
$"ScadaBridge:DeploymentManager:SiteDeploymentMaxParallelism must be at least 1 " +
$"(was {options.SiteDeploymentMaxParallelism}); it is the SemaphoreSlim bound on a bulk " +
$"site deployment's site round-trips, and a non-positive value would deadlock the fan-out.");
builder.RequireThat(options.SiteDeploymentTimeoutPerInstance > TimeSpan.Zero,
$"ScadaBridge:DeploymentManager:SiteDeploymentTimeoutPerInstance must be a positive duration " +
$"(was {options.SiteDeploymentTimeoutPerInstance}); it bounds each instance's site round-trip " +
$"inside a bulk site deployment.");
builder.RequireThat(options.TerminalDeploymentRecordRetention >= TimeSpan.Zero,
$"ScadaBridge:DeploymentManager:TerminalDeploymentRecordRetention must not be negative " +
$"(was {options.TerminalDeploymentRecordRetention}); use TimeSpan.Zero to disable purging of " +
$"terminal deployment records.");
}
}
@@ -57,6 +57,24 @@ public class DeploymentService
/// </summary>
private const string TimeoutFailurePrefix = "Communication failure:";
/// <summary>Rows deleted per terminal-record purge batch.</summary>
private const int TerminalPurgeBatchSize = 500;
/// <summary>Batches deleted per opportunistic sweep, bounding one caller's latency cost.</summary>
private const int TerminalPurgeBatchesPerSweep = 4;
/// <summary>Minimum interval between opportunistic terminal-record sweeps.</summary>
private static readonly TimeSpan TerminalPurgeMinInterval = TimeSpan.FromHours(6);
/// <summary>
/// Ticks of the last opportunistic terminal-record sweep, process-wide.
/// <see cref="DeploymentService"/> is scoped, so this must be static to
/// rate-limit across requests; <see cref="Interlocked"/> makes the
/// claim-the-sweep step a compare-and-swap so concurrent readers do not all
/// sweep at once.
/// </summary>
private static long _lastTerminalPurgeTicks;
/// <summary>
/// Initializes a new instance of <see cref="DeploymentService"/> with all required dependencies.
/// </summary>
@@ -161,28 +179,113 @@ public class DeploymentService
int instanceId,
string user,
CancellationToken cancellationToken = default)
{
// Single-instance deploy = the same three phases the bulk path runs, with a
// batch of one and a private flatten session. Composing both entry points
// out of the same phase helpers is what keeps deployment identity,
// idempotency, lock coverage and optimistic concurrency from drifting apart
// between them.
var prepared = await PrepareDeploymentAsync(
instanceId, user, session: null, cancellationToken);
if (prepared.EarlyResult is { } early)
return early;
using (prepared.LockHandle)
{
var outcome = await SendDeploymentAsync(prepared, cancellationToken);
return await FinalizeDeploymentAsync(prepared, outcome, user, cancellationToken);
}
}
/// <summary>
/// Phase 1 of a deployment (serial, database-bound): validate the state
/// transition, take the per-instance operation lock, mint the deployment id,
/// flatten + validate, run query-before-redeploy reconciliation, stage the
/// <c>PendingDeployment</c> row and insert the <c>InProgress</c>
/// <see cref="DeploymentRecord"/>.
///
/// <para>
/// Everything here touches the scoped, non-thread-safe <c>DbContext</c>, so it
/// must run serially — a bulk site deploy loops this phase before fanning out
/// the phase-2 network waits. On success the returned
/// <see cref="PreparedDeployment"/> owns the operation lock; the CALLER is
/// responsible for disposing it once phase 3 has completed.
/// </para>
/// </summary>
/// <param name="instanceId">Instance being deployed.</param>
/// <param name="user">User attributed on the deployment record and audit rows.</param>
/// <param name="session">
/// Shared flatten session for a batch, or <see langword="null"/> for a private
/// one. Sharing it across a batch is what collapses N identical template-chain
/// walks into one.
/// </param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>
/// A prepared deployment ready to send, or one carrying
/// <see cref="PreparedDeployment.EarlyResult"/> when the deploy resolved before
/// any site round-trip (validation failure, or a reconciled prior deployment).
/// </returns>
private async Task<PreparedDeployment> PrepareDeploymentAsync(
int instanceId,
string user,
FlattenSession? session,
CancellationToken cancellationToken)
{
// Load instance
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
if (instance == null)
return Result<DeploymentRecord>.Failure($"Instance with ID {instanceId} not found.");
return PreparedDeployment.Resolved(Result<DeploymentRecord>.Failure($"Instance with ID {instanceId} not found."));
// Validate state transition
var transitionError = StateTransitionValidator.ValidateTransition(instance.State, "deploy");
if (transitionError != null)
return Result<DeploymentRecord>.Failure(transitionError);
return PreparedDeployment.Resolved(Result<DeploymentRecord>.Failure(transitionError));
// Acquire per-instance operation lock
using var lockHandle = await _lockManager.AcquireAsync(
var lockHandle = await _lockManager.AcquireAsync(
instance.UniqueName, _options.OperationLockTimeout, cancellationToken);
try
{
return await PrepareUnderLockAsync(instance, user, session, lockHandle, cancellationToken);
}
catch
{
// The lock is handed to the caller only on the success path; any fault
// during preparation must release it here or a failed prepare would
// wedge the instance until the process restarts.
lockHandle.Dispose();
throw;
}
}
/// <summary>
/// The body of <see cref="PrepareDeploymentAsync"/> that runs while the
/// per-instance operation lock is held. Split out so the lock can be released
/// on every fault path with one try/catch rather than a nested one per step.
/// </summary>
private async Task<PreparedDeployment> PrepareUnderLockAsync(
Instance instance,
string user,
FlattenSession? session,
IDisposable lockHandle,
CancellationToken cancellationToken)
{
var instanceId = instance.Id;
// Generate unique deployment ID
var deploymentId = Guid.NewGuid().ToString("N");
// Flatten configuration (captures template state at this point in time)
var flattenResult = await _flatteningPipeline.FlattenAndValidateAsync(instanceId, cancellationToken);
var flattenResult = await _flatteningPipeline.FlattenAndValidateAsync(
instanceId, cancellationToken, validateScripts: true, session);
if (flattenResult.IsFailure)
return Result<DeploymentRecord>.Failure($"Validation failed: {flattenResult.Error}");
{
lockHandle.Dispose();
return PreparedDeployment.Resolved(
Result<DeploymentRecord>.Failure($"Validation failed: {flattenResult.Error}"));
}
var flattenedConfig = flattenResult.Value.Configuration;
var revisionHash = flattenResult.Value.RevisionHash;
@@ -200,8 +303,9 @@ public class DeploymentService
validationResult.Errors.Count,
string.Join("; ", validationResult.Errors.Select(e => e.Message)));
return Result<DeploymentRecord>.Failure(
$"Pre-deployment validation failed: {validationResult.SummarizeErrors()}");
lockHandle.Dispose();
return PreparedDeployment.Resolved(Result<DeploymentRecord>.Failure(
$"Pre-deployment validation failed: {validationResult.SummarizeErrors()}"));
}
// Serialize for transmission (also the payload stored in the deployed
@@ -218,7 +322,10 @@ public class DeploymentService
var reconciled = await TryReconcileWithSiteAsync(
instance, revisionHash, configJson, user, cancellationToken);
if (reconciled != null)
return Result<DeploymentRecord>.Success(reconciled);
{
lockHandle.Dispose();
return PreparedDeployment.Resolved(Result<DeploymentRecord>.Success(reconciled));
}
// Notify-and-fetch: the site fetches the staged config from
// CentralFetchBaseUrl, so a deploy is impossible without it. Fail fast
@@ -227,8 +334,11 @@ public class DeploymentService
// confusing downstream site-fetch failure (and no InProgress record is
// stranded).
if (string.IsNullOrEmpty(_commOptions.CentralFetchBaseUrl))
return Result<DeploymentRecord>.Failure(
"CentralFetchBaseUrl is not configured — required for deployment (notify-and-fetch).");
{
lockHandle.Dispose();
return PreparedDeployment.Resolved(Result<DeploymentRecord>.Failure(
"CentralFetchBaseUrl is not configured — required for deployment (notify-and-fetch)."));
}
// Create the deployment record directly in InProgress.
//
@@ -275,12 +385,8 @@ public class DeploymentService
deploymentId, instance.UniqueName, revisionHash, user, stagedAt,
_commOptions.CentralFetchBaseUrl, token);
_logger.LogInformation(
"Sending deployment {DeploymentId} for instance {Instance} to site {SiteId} (notify-and-fetch)",
deploymentId, instance.UniqueName, siteId);
// Cleanup of the staged PendingDeployment is TTL-based ONLY — the row
// is deliberately NOT deleted on success or in the catch. On a
// is deliberately NOT deleted on success or on failure. On a
// central-side Ask timeout the site may have applied AND told the
// standby node to fetch; deleting now would 404 that in-flight
// standby fetch and break failover. Supersession bounds pending rows
@@ -288,8 +394,89 @@ public class DeploymentService
// leaving rows for TTL purge is safe. Expired rows are swept by the
// central PendingDeploymentPurgeActor singleton on its
// CommunicationOptions.PendingDeploymentPurgeInterval cadence.
var response = await _communicationService.RefreshDeploymentAsync(siteId, command, cancellationToken);
return new PreparedDeployment(
instance, deploymentId, record, revisionHash, configJson,
siteId, command, lockHandle, EarlyResult: null);
}
catch (Exception ex)
{
// Staging failed before anything was sent. Record the failure exactly
// as the post-send path does (never leave the record InProgress) and
// release the lock — there is no phase 2 or 3 to run.
await MarkDeploymentFailedAsync(record, instance, deploymentId, user, ex);
lockHandle.Dispose();
return PreparedDeployment.Resolved(FailureResultFor(ex));
}
}
/// <summary>
/// Phase 2 of a deployment (parallelisable, network-bound): the
/// <c>RefreshDeploymentCommand</c> round-trip to the site. Touches no
/// repository and mutates no shared state, which is what makes it safe to fan
/// out across a batch while phases 1 and 3 stay serial.
/// </summary>
/// <param name="prepared">The prepared deployment to send.</param>
/// <param name="cancellationToken">Cancellation token, already carrying any per-instance deadline.</param>
/// <returns>The site's response, or the exception that prevented one.</returns>
private async Task<SendOutcome> SendDeploymentAsync(
PreparedDeployment prepared,
CancellationToken cancellationToken)
{
try
{
_logger.LogInformation(
"Sending deployment {DeploymentId} for instance {Instance} to site {SiteId} (notify-and-fetch)",
prepared.DeploymentId, prepared.Instance.UniqueName, prepared.SiteIdentifier);
var response = await _communicationService.RefreshDeploymentAsync(
prepared.SiteIdentifier, prepared.Command, cancellationToken);
return new SendOutcome(response, null);
}
catch (Exception ex)
{
return new SendOutcome(null, ex);
}
}
/// <summary>
/// Phase 3 of a deployment (serial, database-bound): commit the terminal
/// status, apply post-success side effects, and write the audit row.
/// </summary>
/// <param name="prepared">The prepared deployment whose send just completed.</param>
/// <param name="outcome">The site response or the fault from phase 2.</param>
/// <param name="user">User attributed on the audit rows.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The deployment result for the caller.</returns>
private async Task<Result<DeploymentRecord>> FinalizeDeploymentAsync(
PreparedDeployment prepared,
SendOutcome outcome,
string user,
CancellationToken cancellationToken)
{
var instance = prepared.Instance;
var record = prepared.Record;
var deploymentId = prepared.DeploymentId;
var instanceId = instance.Id;
if (outcome.Error is { } sendError)
{
// Any fault out of the site round-trip (timeout, cancellation,
// transport, serialization) must leave the deployment record as
// Failed -- the design requires an interrupted deployment to be
// treated as failed, never stuck in InProgress.
await MarkDeploymentFailedAsync(record, instance, deploymentId, user, sendError);
_logger.LogError(sendError,
"Deployment {DeploymentId} for instance {Instance} failed",
deploymentId, instance.UniqueName);
return FailureResultFor(sendError);
}
var response = outcome.Response!;
try
{
// Update status based on site response.
record.Status = response.Status;
record.ErrorMessage = response.ErrorMessage;
@@ -322,7 +509,7 @@ public class DeploymentService
// logged loudly for operator reconciliation but must not flip
// the already-committed Success record back to Failed.
await ApplyPostSuccessSideEffectsAsync(
instance, deploymentId, revisionHash, configJson,
instance, deploymentId, prepared.RevisionHash, prepared.ConfigJson,
forceEnabledState: true, cancellationToken);
}
@@ -345,59 +532,364 @@ public class DeploymentService
}
catch (Exception ex)
{
// Any exception out of the try (timeout,
// cancellation, transport, serialization, DB) must leave the
// deployment record as Failed -- the design requires an interrupted
// deployment to be treated as failed, never stuck in InProgress.
//
// The failure-status write must NOT use the
// operation's cancellation token. If the operation was cancelled or
// timed out, that token is already cancelled and the cleanup writes
// would themselves throw before the Failed status is persisted.
// Use CancellationToken.None so the failure is durably recorded.
var isTimeout = ex is TimeoutException or OperationCanceledException or Akka.Actor.AskTimeoutException;
record.Status = DeploymentStatus.Failed;
record.ErrorMessage = isTimeout
? $"{TimeoutFailurePrefix} {ex.Message}"
: $"Deployment error: {ex.Message}";
record.CompletedAt = DateTimeOffset.UtcNow;
try
{
await _repository.UpdateDeploymentRecordAsync(record, CancellationToken.None);
// Note: if the staging SaveChangesAsync above was interrupted, an
// Added PendingDeployment may still be tracked and will be
// committed by this cleanup save. That row is orphaned (no
// RefreshDeploymentCommand was sent, so no site holds its token)
// and is removed by TTL purge / superseded by the next deploy --
// harmless.
await _repository.SaveChangesAsync(CancellationToken.None);
NotifyStatusChange(record);
await _auditService.LogAsync(user, "DeployFailed", "Instance", instanceId.ToString(),
instance.UniqueName, new { DeploymentId = deploymentId, Error = ex.Message },
CancellationToken.None);
}
catch (Exception cleanupEx)
{
// The deployment already failed; a failed cleanup write must not
// mask the original error. Log loudly so an operator can reconcile.
_logger.LogError(cleanupEx,
"Failed to persist Failed status for deployment {DeploymentId} of instance {Instance} " +
"after deployment error: {Error}",
deploymentId, instance.UniqueName, ex.Message);
}
// A fault in the post-send persistence path is handled identically to
// a send fault: the record must never be left InProgress.
await MarkDeploymentFailedAsync(record, instance, deploymentId, user, ex);
_logger.LogError(ex,
"Deployment {DeploymentId} for instance {Instance} failed",
deploymentId, instance.UniqueName);
return Result<DeploymentRecord>.Failure(
isTimeout
return FailureResultFor(ex);
}
}
/// <summary>
/// Writes the terminal <see cref="DeploymentStatus.Failed"/> status (plus the
/// failure audit row) for a deployment that faulted, on any phase.
///
/// <para>
/// The failure-status write deliberately uses <see cref="CancellationToken.None"/>:
/// if the operation was cancelled or timed out, the operation's own token is
/// already cancelled and these cleanup writes would themselves throw before
/// the Failed status was persisted, leaving the record stuck InProgress.
/// </para>
///
/// <para>
/// A fault DURING cleanup is logged loudly and swallowed — it must not mask
/// the original error the caller is about to report.
/// </para>
/// </summary>
private async Task MarkDeploymentFailedAsync(
DeploymentRecord record,
Instance instance,
string deploymentId,
string user,
Exception cause)
{
record.Status = DeploymentStatus.Failed;
record.ErrorMessage = IsTimeoutFault(cause)
? $"{TimeoutFailurePrefix} {cause.Message}"
: $"Deployment error: {cause.Message}";
record.CompletedAt = DateTimeOffset.UtcNow;
try
{
await _repository.UpdateDeploymentRecordAsync(record, CancellationToken.None);
// Note: if the staging SaveChangesAsync was interrupted, an Added
// PendingDeployment may still be tracked and will be committed by this
// cleanup save. That row is orphaned (no RefreshDeploymentCommand was
// sent, so no site holds its token) and is removed by TTL purge /
// superseded by the next deploy -- harmless.
await _repository.SaveChangesAsync(CancellationToken.None);
NotifyStatusChange(record);
await _auditService.LogAsync(user, "DeployFailed", "Instance", instance.Id.ToString(),
instance.UniqueName, new { DeploymentId = deploymentId, Error = cause.Message },
CancellationToken.None);
}
catch (Exception cleanupEx)
{
_logger.LogError(cleanupEx,
"Failed to persist Failed status for deployment {DeploymentId} of instance {Instance} " +
"after deployment error: {Error}",
deploymentId, instance.UniqueName, cause.Message);
}
}
/// <summary>
/// True when a deployment fault is a timeout/cancellation rather than a hard
/// error. Drives both the <see cref="TimeoutFailurePrefix"/> marker (which the
/// query-before-redeploy trigger reads) and the caller-facing wording.
/// </summary>
private static bool IsTimeoutFault(Exception ex) =>
ex is TimeoutException or OperationCanceledException or Akka.Actor.AskTimeoutException;
/// <summary>Maps a deployment fault to the caller-facing failure result.</summary>
private static Result<DeploymentRecord> FailureResultFor(Exception ex) =>
Result<DeploymentRecord>.Failure(
IsTimeoutFault(ex)
? $"Deployment timed out: {ex.Message}"
: $"Deployment failed: {ex.Message}");
/// <summary>
/// A deployment that has cleared phase 1 and is ready for its site round-trip,
/// OR one that resolved during phase 1 (carrying <see cref="EarlyResult"/>).
///
/// <para>
/// When <see cref="EarlyResult"/> is <see langword="null"/> this record OWNS the
/// per-instance operation lock in <see cref="LockHandle"/>, and the caller must
/// dispose it after phase 3. When <see cref="EarlyResult"/> is set the lock has
/// already been released and every other member is a placeholder.
/// </para>
/// </summary>
private sealed record PreparedDeployment(
Instance Instance,
string DeploymentId,
DeploymentRecord Record,
string RevisionHash,
string ConfigJson,
string SiteIdentifier,
RefreshDeploymentCommand Command,
IDisposable? LockHandle,
Result<DeploymentRecord>? EarlyResult)
{
/// <summary>Builds a phase-1-resolved deployment carrying the given result and holding no lock.</summary>
/// <param name="result">The result to hand back to the caller.</param>
/// <returns>A prepared deployment whose <see cref="EarlyResult"/> is set.</returns>
public static PreparedDeployment Resolved(Result<DeploymentRecord> result) =>
new(null!, string.Empty, null!, string.Empty, string.Empty, string.Empty, null!, null, result);
}
/// <summary>
/// Result of phase 2: either the site's response or the exception that
/// prevented one. Exactly one is non-null.
/// </summary>
private readonly record struct SendOutcome(DeploymentStatusResponse? Response, Exception? Error);
/// <summary>
/// Deploy every deployable instance at a site in one operation.
///
/// <para>
/// Structured as the three deployment phases with the middle one fanned out:
/// </para>
/// <list type="number">
/// <item>
/// <b>Prepare (serial).</b> Every instance is flattened, validated, staged
/// and given an <c>InProgress</c> record on the caller's single scoped
/// <c>DbContext</c>. All instances share ONE <see cref="FlattenSession"/>,
/// so a template chain common to N instances is walked once, and the
/// session-global queries (shared scripts, schema library, the site's data
/// connections) run once for the whole batch instead of once per instance.
/// </item>
/// <item>
/// <b>Send (bounded parallel).</b> Site round-trips run concurrently up to
/// <see cref="DeploymentManagerOptions.SiteDeploymentMaxParallelism"/>,
/// each under its own
/// <see cref="DeploymentManagerOptions.SiteDeploymentTimeoutPerInstance"/>
/// deadline, so one wedged instance cannot stall the batch. This phase
/// touches no repository — that is precisely why it is the only phase that
/// may run in parallel against a non-thread-safe <c>DbContext</c>.
/// </item>
/// <item>
/// <b>Finalize (serial).</b> Terminal statuses, post-success side effects
/// and audit rows are committed one instance at a time, then each
/// instance's operation lock is released.
/// </item>
/// </list>
///
/// <para>
/// Semantics per instance are byte-for-byte those of
/// <see cref="DeployInstanceAsync"/> — its own deployment id and revision hash,
/// its own operation lock held across the whole operation, its own
/// optimistically-concurrent status record, and the same query-before-redeploy
/// idempotency check. Instances that cannot be deployed (wrong state, failed
/// validation, lock already held) are reported as failed rows; the batch is
/// NOT all-or-nothing, mirroring the artifact deployment path.
/// </para>
/// </summary>
/// <param name="siteId">The database ID of the site whose instances are deployed.</param>
/// <param name="user">The username initiating the deployment, recorded on every record and audit row.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A per-instance result matrix, or a failure result when the site itself cannot be resolved.</returns>
public async Task<Result<SiteDeploymentSummary>> DeploySiteAsync(
int siteId,
string user,
CancellationToken cancellationToken = default)
{
var site = await _siteRepository.GetSiteByIdAsync(siteId, cancellationToken);
if (site == null)
return Result<SiteDeploymentSummary>.Failure($"Site with ID {siteId} not found.");
var instances = await _siteRepository.GetInstancesBySiteIdAsync(siteId, cancellationToken);
if (instances.Count == 0)
return Result<SiteDeploymentSummary>.Success(new SiteDeploymentSummary(site.SiteIdentifier, [], 0, 0));
// ---- Phase 1: prepare, serially, on ONE shared flatten session. ----
var session = _flatteningPipeline.CreateSession();
var prepared = new List<PreparedDeployment>(instances.Count);
var results = new List<InstanceDeploymentResult>(instances.Count);
foreach (var instance in instances)
{
cancellationToken.ThrowIfCancellationRequested();
PreparedDeployment step;
try
{
step = await PrepareDeploymentAsync(instance.Id, user, session, cancellationToken);
}
catch (Exception ex)
{
// A prepare fault (most commonly a TimeoutException from the
// operation lock because another operation holds this instance)
// fails only this instance. Recording it and moving on is what
// makes a bulk deploy usable while individual instances are busy.
_logger.LogWarning(ex,
"Preparing instance {Instance} for bulk deployment of site {SiteId} failed",
instance.UniqueName, site.SiteIdentifier);
results.Add(new InstanceDeploymentResult(
instance.Id, instance.UniqueName, null, false, ex.Message));
continue;
}
if (step.EarlyResult is { } early)
{
results.Add(ToInstanceResult(instance.Id, instance.UniqueName, early));
continue;
}
prepared.Add(step);
}
// ---- Phase 2: bounded-parallel site round-trips. ----
var outcomes = await SendPreparedAsync(prepared, cancellationToken);
// ---- Phase 3: finalize, serially, releasing each lock as we go. ----
for (var i = 0; i < prepared.Count; i++)
{
var step = prepared[i];
try
{
var result = await FinalizeDeploymentAsync(step, outcomes[i], user, cancellationToken);
results.Add(ToInstanceResult(step.Instance.Id, step.Instance.UniqueName, result, step.DeploymentId));
}
finally
{
step.LockHandle?.Dispose();
}
}
var summary = new SiteDeploymentSummary(
site.SiteIdentifier,
results,
results.Count(r => r.Success),
results.Count(r => !r.Success));
_logger.LogInformation(
"Bulk deployment of site {SiteId} complete: {SuccessCount} succeeded, {FailureCount} failed " +
"({ChainLoads} template-chain load(s) across {InstanceCount} instance(s))",
site.SiteIdentifier, summary.SuccessCount, summary.FailureCount,
session.ChainLoads, instances.Count);
return Result<SiteDeploymentSummary>.Success(summary);
}
/// <summary>
/// Runs phase 2 for a whole batch: every prepared deployment's site round-trip,
/// concurrent up to <see cref="DeploymentManagerOptions.SiteDeploymentMaxParallelism"/>
/// and each bounded by
/// <see cref="DeploymentManagerOptions.SiteDeploymentTimeoutPerInstance"/>.
/// Outcomes are returned positionally so phase 3 can pair them back with their
/// prepared deployment.
/// </summary>
private async Task<SendOutcome[]> SendPreparedAsync(
IReadOnlyList<PreparedDeployment> prepared,
CancellationToken cancellationToken)
{
var outcomes = new SendOutcome[prepared.Count];
if (prepared.Count == 0)
return outcomes;
using var gate = new SemaphoreSlim(_options.SiteDeploymentMaxParallelism);
var sends = prepared.Select(async (step, index) =>
{
await gate.WaitAsync(cancellationToken);
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_options.SiteDeploymentTimeoutPerInstance);
outcomes[index] = await SendDeploymentAsync(step, cts.Token);
}
finally
{
gate.Release();
}
});
await Task.WhenAll(sends);
return outcomes;
}
/// <summary>Maps a per-instance deployment result into the bulk summary row shape.</summary>
private static InstanceDeploymentResult ToInstanceResult(
int instanceId,
string uniqueName,
Result<DeploymentRecord> result,
string? deploymentId = null) =>
result.IsSuccess
? new InstanceDeploymentResult(
instanceId, uniqueName, result.Value.DeploymentId, true, null)
: new InstanceDeploymentResult(
instanceId, uniqueName, deploymentId, false, result.Error);
/// <summary>
/// Opportunistic retention sweep for TERMINAL deployment records, rate-limited
/// to once every <see cref="TerminalPurgeMinInterval"/> per process and bounded
/// to <see cref="TerminalPurgeBatchesPerSweep"/> batches per call.
///
/// <para>
/// Swept on read (the deployment-list path) rather than from a timer for the
/// same reason expired secured writes are: the component owns no scheduling
/// infrastructure of its own, and the read path is exactly the operation that
/// suffers when the table is allowed to grow without bound. Both central nodes
/// may sweep; the delete is idempotent, so a duplicate pass is a no-op.
/// </para>
///
/// <para>
/// Best-effort: a failed sweep is logged and swallowed. Retention maintenance
/// must never fail the operator's actual query.
/// </para>
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The number of records purged by this call (0 when the sweep was skipped).</returns>
public async Task<int> TryPurgeTerminalDeploymentRecordsAsync(CancellationToken cancellationToken = default)
{
var retention = _options.TerminalDeploymentRecordRetention;
if (retention <= TimeSpan.Zero)
return 0;
var now = DateTimeOffset.UtcNow;
var last = Interlocked.Read(ref _lastTerminalPurgeTicks);
if (last != 0 && now.UtcTicks - last < TerminalPurgeMinInterval.Ticks)
return 0;
// Claim the sweep. A loser of this CAS returns immediately rather than
// running a redundant second pass.
if (Interlocked.CompareExchange(ref _lastTerminalPurgeTicks, now.UtcTicks, last) != last)
return 0;
var cutoff = now - retention;
var purged = 0;
try
{
for (var batch = 0; batch < TerminalPurgeBatchesPerSweep; batch++)
{
var deleted = await _repository.PurgeTerminalDeploymentRecordsAsync(
cutoff, TerminalPurgeBatchSize, cancellationToken);
purged += deleted;
if (deleted < TerminalPurgeBatchSize)
break;
}
if (purged > 0)
{
_logger.LogInformation(
"Purged {Count} terminal deployment record(s) completed before {Cutoff} " +
"(retention {Retention}).",
purged, cutoff, retention);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Terminal deployment-record retention sweep failed; retrying after {Interval}.",
TerminalPurgeMinInterval);
}
return purged;
}
/// <summary>
@@ -1178,3 +1670,37 @@ public record DeploymentComparisonResult(
bool IsStale,
DateTimeOffset DeployedAt,
ConfigurationDiff? Diff = null);
/// <summary>
/// One instance's outcome inside a bulk site deployment.
/// </summary>
/// <param name="InstanceId">Database id of the instance.</param>
/// <param name="UniqueName">The instance's unique name (also its operation-lock key).</param>
/// <param name="DeploymentId">
/// The deployment id minted for this instance, or <see langword="null"/> when the
/// instance never got that far (state-transition rejection, lock contention).
/// </param>
/// <param name="Success">Whether the site confirmed the apply.</param>
/// <param name="ErrorMessage">Failure detail; <see langword="null"/> on success.</param>
public record InstanceDeploymentResult(
int InstanceId,
string UniqueName,
string? DeploymentId,
bool Success,
string? ErrorMessage);
/// <summary>
/// Result matrix of a bulk site deployment. Mirrors the shape of
/// <c>ArtifactDeploymentSummary</c>: successes are not rolled back when other
/// instances fail, and each failed instance is individually retryable through the
/// ordinary single-instance deploy.
/// </summary>
/// <param name="SiteIdentifier">The site's string identifier (e.g. <c>site-a</c>).</param>
/// <param name="InstanceResults">Per-instance outcomes.</param>
/// <param name="SuccessCount">Number of instances the site confirmed.</param>
/// <param name="FailureCount">Number of instances that failed for any reason.</param>
public record SiteDeploymentSummary(
string SiteIdentifier,
IReadOnlyList<InstanceDeploymentResult> InstanceResults,
int SuccessCount,
int FailureCount);
@@ -0,0 +1,257 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
/// <summary>
/// Memoises the repository reads that <see cref="FlatteningPipeline"/> performs,
/// for the lifetime of ONE flatten/validate session.
///
/// <para>
/// Without a session every instance flatten re-walks its whole template
/// inheritance chain (one query per link), re-loads the compositions of every
/// template in that chain and of every composed chain it reaches, and re-issues
/// the three session-global queries — shared scripts, the shared-schema library,
/// and the target site's data connections. For a bulk deploy of N instances off
/// the same template that is N× the same work; even a single instance re-loads a
/// composed chain once per composing template.
/// </para>
///
/// <para>
/// <b>Cache key.</b> Templates are keyed on <c>(id, ITemplateGraphWatermark
/// version)</c> and chain MEMBERSHIP additionally on the watermark's
/// <see cref="ITemplateGraphWatermark.StructureVersion"/>, so a template edited
/// mid-session is re-read rather than served from the memo. The session is also
/// short-lived by construction — it is created per deploy/validate operation and
/// discarded with it — so a cached graph can never outlive the operation that
/// captured it. That is the design's "template state is captured at the time of
/// flatten" guarantee, unchanged: the session narrows the capture window, it does
/// not widen it.
/// </para>
///
/// <para>
/// <b>Threading.</b> A session is NOT thread-safe and must not be shared across
/// threads. It caches entities materialised by one scoped <c>DbContext</c>, which
/// is itself single-threaded, and it hands out tracked entities that the
/// in-flight bundle importer relies on observing. <c>DeploySiteAsync</c> honours
/// this by running the whole prepare (flatten) phase serially on one scope and
/// parallelising only the site round-trips, which touch no repository at all.
/// </para>
/// </summary>
public sealed class FlattenSession
{
private readonly ITemplateGraphWatermark _watermark;
private readonly Dictionary<int, (long Version, Template Template)> _templates = new();
private readonly Dictionary<int, CachedChain> _chains = new();
private readonly Dictionary<int, (long Version, IReadOnlyList<TemplateComposition> Compositions)> _compositions = new();
private readonly Dictionary<int, IReadOnlyDictionary<int, DataConnection>> _dataConnections = new();
private IReadOnlyList<ResolvedScript>? _sharedScripts;
private IReadOnlyDictionary<string, string>? _schemaLibrary;
/// <summary>Initializes a session bound to the process-wide graph watermark.</summary>
/// <param name="watermark">Watermark supplying the per-template versions used as cache keys.</param>
public FlattenSession(ITemplateGraphWatermark watermark)
{
_watermark = watermark ?? throw new ArgumentNullException(nameof(watermark));
}
/// <summary>
/// Number of template-chain walks that actually hit the repository. Reset
/// never; used by tests to assert that N instances sharing a template load
/// that chain exactly once.
/// </summary>
public int ChainLoads { get; private set; }
/// <summary>Number of single-template repository reads that missed the memo.</summary>
public int TemplateLoads { get; private set; }
/// <summary>Number of per-template composition queries that missed the memo.</summary>
public int CompositionLoads { get; private set; }
/// <summary>Number of times the session-global queries (shared scripts, schema library) ran.</summary>
public int GlobalLoads { get; private set; }
/// <summary>Number of per-site data-connection queries that missed the memo.</summary>
public int DataConnectionLoads { get; private set; }
/// <summary>
/// Every template id this session resolved — the union of all inheritance and
/// composed chains it walked. The staleness fast path records the watermark
/// version of exactly this set, so a change to ANY template that fed a
/// flatten invalidates the memoised revision hash.
/// </summary>
public IReadOnlyCollection<int> VisitedTemplateIds => _templates.Keys;
/// <summary>
/// Returns the inheritance chain for <paramref name="templateId"/> (the
/// template itself, then each ancestor), loading it through
/// <paramref name="loadTemplate"/> on a miss.
/// </summary>
/// <param name="templateId">Root of the chain to build.</param>
/// <param name="loadTemplate">Repository read for one template with its children.</param>
/// <returns>The chain, ordered leaf-first.</returns>
public async Task<IReadOnlyList<Template>> GetChainAsync(
int templateId,
Func<int, Task<Template?>> loadTemplate)
{
var structure = _watermark.StructureVersion;
if (_chains.TryGetValue(templateId, out var cached) && IsChainCurrent(cached, structure))
return cached.Chain;
var chain = new List<Template>();
var memberVersions = new Dictionary<int, long>();
var currentId = (int?)templateId;
// Guard against a cyclic parent edge. Acyclicity is enforced on save, but
// this walk must not hang if a bad row ever reaches the database.
var seen = new HashSet<int>();
while (currentId.HasValue && seen.Add(currentId.Value))
{
memberVersions[currentId.Value] = _watermark.GetTemplateVersion(currentId.Value);
var template = await GetTemplateAsync(currentId.Value, loadTemplate);
if (template == null) break;
chain.Add(template);
currentId = template.ParentTemplateId;
}
ChainLoads++;
_chains[templateId] = new CachedChain(structure, memberVersions, chain);
return chain;
}
/// <summary>
/// A memoised chain plus the exact watermark readings it was built under.
/// </summary>
/// <param name="Structure">Structure version, covering chain MEMBERSHIP.</param>
/// <param name="MemberVersions">Per-template version of every link, covering chain CONTENTS.</param>
/// <param name="Chain">The chain itself, ordered leaf-first.</param>
private sealed record CachedChain(
long Structure,
IReadOnlyDictionary<int, long> MemberVersions,
IReadOnlyList<Template> Chain);
/// <summary>
/// A cached chain is current only when BOTH the graph shape and every member's
/// own version are unchanged.
///
/// <para>
/// Both halves are load-bearing. Structure alone would miss an ordinary member
/// edit (an attribute added to a template already in the chain), which changes
/// what the flattener produces without changing which templates it walks.
/// Member versions alone would miss a re-parent, which changes membership
/// without touching any surviving member. Checking only one would let a session
/// serve a chain that no longer reflects the database.
/// </para>
/// </summary>
private bool IsChainCurrent(CachedChain cached, long structure)
{
if (cached.Structure != structure)
return false;
foreach (var (templateId, version) in cached.MemberVersions)
{
if (_watermark.GetTemplateVersion(templateId) != version)
return false;
}
return true;
}
/// <summary>
/// Returns one template with its children, loading it through
/// <paramref name="loadTemplate"/> on a miss or when its watermark version moved.
/// </summary>
/// <param name="templateId">Template to read.</param>
/// <param name="loadTemplate">Repository read for one template with its children.</param>
/// <returns>The template, or <see langword="null"/> when it does not exist.</returns>
public async Task<Template?> GetTemplateAsync(int templateId, Func<int, Task<Template?>> loadTemplate)
{
var version = _watermark.GetTemplateVersion(templateId);
if (_templates.TryGetValue(templateId, out var cached) && cached.Version == version)
return cached.Template;
var template = await loadTemplate(templateId);
TemplateLoads++;
if (template != null)
_templates[templateId] = (version, template);
return template;
}
/// <summary>
/// Returns the composition rows declared directly on <paramref name="templateId"/>.
/// </summary>
/// <param name="templateId">Template whose compositions are read.</param>
/// <param name="loadCompositions">Repository read for one template's compositions.</param>
/// <returns>The composition rows.</returns>
public async Task<IReadOnlyList<TemplateComposition>> GetCompositionsAsync(
int templateId,
Func<int, Task<IReadOnlyList<TemplateComposition>>> loadCompositions)
{
var version = _watermark.GetTemplateVersion(templateId);
if (_compositions.TryGetValue(templateId, out var cached) && cached.Version == version)
return cached.Compositions;
var compositions = await loadCompositions(templateId);
CompositionLoads++;
_compositions[templateId] = (version, compositions);
return compositions;
}
/// <summary>
/// Returns the site's data connections keyed by id. Cached per site for the
/// session — a bulk site deploy resolves them once for the whole batch.
/// </summary>
/// <param name="siteId">Site whose connections are read.</param>
/// <param name="loadConnections">Repository read for a site's data connections.</param>
/// <returns>The site's connections keyed by database id.</returns>
public async Task<IReadOnlyDictionary<int, DataConnection>> GetDataConnectionsAsync(
int siteId,
Func<int, Task<IReadOnlyDictionary<int, DataConnection>>> loadConnections)
{
if (_dataConnections.TryGetValue(siteId, out var cached))
return cached;
var connections = await loadConnections(siteId);
DataConnectionLoads++;
_dataConnections[siteId] = connections;
return connections;
}
/// <summary>
/// Returns the shared-script set used for semantic validation. Hoisted to the
/// session so a bulk deploy issues this query once instead of once per instance.
/// </summary>
/// <param name="loadSharedScripts">Repository read for all shared scripts.</param>
/// <returns>The resolved shared scripts.</returns>
public async Task<IReadOnlyList<ResolvedScript>> GetSharedScriptsAsync(
Func<Task<IReadOnlyList<ResolvedScript>>> loadSharedScripts)
{
if (_sharedScripts != null)
return _sharedScripts;
_sharedScripts = await loadSharedScripts();
GlobalLoads++;
return _sharedScripts;
}
/// <summary>
/// Returns the shared JSON-Schema library as a name → schema-JSON map, hoisted
/// to the session for the same reason as the shared scripts.
/// </summary>
/// <param name="loadSchemaLibrary">Repository read building the schema library map.</param>
/// <returns>The schema library.</returns>
public async Task<IReadOnlyDictionary<string, string>> GetSchemaLibraryAsync(
Func<Task<IReadOnlyDictionary<string, string>>> loadSchemaLibrary)
{
if (_schemaLibrary != null)
return _schemaLibrary;
_schemaLibrary = await loadSchemaLibrary();
GlobalLoads++;
return _schemaLibrary;
}
}
@@ -1,6 +1,7 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
@@ -23,6 +24,7 @@ public class FlatteningPipeline : IFlatteningPipeline
private readonly ValidationService _validationService;
private readonly RevisionHashService _revisionHashService;
private readonly ISharedSchemaRepository _sharedSchemaRepo;
private readonly ITemplateGraphWatermark _watermark;
/// <summary>Initializes a new <see cref="FlatteningPipeline"/> with the required template engine and site repositories and services.</summary>
/// <param name="templateRepo">Repository for loading templates and instance data.</param>
@@ -35,13 +37,19 @@ public class FlatteningPipeline : IFlatteningPipeline
/// look up <c>lib:Name</c> library references so a dangling reference in any validated
/// script schema becomes a deploy-blocking error.
/// </param>
/// <param name="watermark">
/// Process-wide template version watermark. Supplies the per-template version
/// used as the <see cref="FlattenSession"/> cache-key discriminator, so a
/// memoised chain is dropped the moment any of its templates is edited.
/// </param>
public FlatteningPipeline(
ITemplateEngineRepository templateRepo,
ISiteRepository siteRepo,
FlatteningService flatteningService,
ValidationService validationService,
RevisionHashService revisionHashService,
ISharedSchemaRepository sharedSchemaRepo)
ISharedSchemaRepository sharedSchemaRepo,
ITemplateGraphWatermark watermark)
{
_templateRepo = templateRepo;
_siteRepo = siteRepo;
@@ -49,21 +57,35 @@ public class FlatteningPipeline : IFlatteningPipeline
_validationService = validationService;
_revisionHashService = revisionHashService;
_sharedSchemaRepo = sharedSchemaRepo;
_watermark = watermark;
}
/// <inheritdoc />
public FlattenSession CreateSession() => new(_watermark);
/// <inheritdoc />
public async Task<Result<FlatteningPipelineResult>> FlattenAndValidateAsync(
int instanceId,
CancellationToken cancellationToken = default,
bool validateScripts = true)
bool validateScripts = true,
FlattenSession? session = null)
{
// A caller that supplies no session gets a private one. That still pays
// off inside a single flatten: the composed-chain walk below reaches the
// same template through several composing parents, and the memo collapses
// those to one load each.
session ??= CreateSession();
// Load instance with full graph
var instance = await _templateRepo.GetInstanceByIdAsync(instanceId, cancellationToken);
if (instance == null)
return Result<FlatteningPipelineResult>.Failure($"Instance with ID {instanceId} not found.");
Task<Commons.Entities.Templates.Template?> LoadTemplate(int id) =>
_templateRepo.GetTemplateWithChildrenAsync(id, cancellationToken);
// Build template chain
var templateChain = await BuildTemplateChainAsync(instance.TemplateId, cancellationToken);
var templateChain = await session.GetChainAsync(instance.TemplateId, LoadTemplate);
if (templateChain.Count == 0)
return Result<FlatteningPipelineResult>.Failure("Template chain is empty.");
@@ -84,7 +106,9 @@ public class FlatteningPipeline : IFlatteningPipeline
{
if (!processedTemplateIds.Add(template.Id)) continue;
var compositions = await _templateRepo.GetCompositionsByTemplateIdAsync(template.Id, cancellationToken);
var compositions = await session.GetCompositionsAsync(
template.Id,
id => _templateRepo.GetCompositionsByTemplateIdAsync(id, cancellationToken));
if (compositions.Count == 0) continue;
compositionMap[template.Id] = compositions;
@@ -92,7 +116,7 @@ public class FlatteningPipeline : IFlatteningPipeline
{
if (composedChains.ContainsKey(comp.ComposedTemplateId)) continue;
var composedChain = await BuildTemplateChainAsync(comp.ComposedTemplateId, cancellationToken);
var composedChain = await session.GetChainAsync(comp.ComposedTemplateId, LoadTemplate);
composedChains[comp.ComposedTemplateId] = composedChain;
pendingChains.Enqueue(composedChain);
}
@@ -100,7 +124,9 @@ public class FlatteningPipeline : IFlatteningPipeline
}
// Load data connections for the site
var dataConnections = await LoadDataConnectionsAsync(instance.SiteId, cancellationToken);
var dataConnections = await session.GetDataConnectionsAsync(
instance.SiteId,
siteId => LoadDataConnectionsAsync(siteId, cancellationToken));
// Flatten
var flattenResult = _flatteningService.Flatten(
@@ -111,15 +137,20 @@ public class FlatteningPipeline : IFlatteningPipeline
var config = flattenResult.Value;
// Load shared scripts for semantic validation
// Load shared scripts for semantic validation. Session-global: identical
// for every instance in a batch, so hoisted to one query per session
// (the ArtifactDeploymentService global-hoist shape).
var resolvedSharedScripts = await session.GetSharedScriptsAsync(async () =>
{
var sharedScriptEntities = await _templateRepo.GetAllSharedScriptsAsync(cancellationToken);
var resolvedSharedScripts = sharedScriptEntities.Select(s => new ResolvedScript
return (IReadOnlyList<ResolvedScript>)sharedScriptEntities.Select(s => new ResolvedScript
{
CanonicalName = s.Name,
Code = s.Code,
ParameterDefinitions = s.ParameterDefinitions,
ReturnDefinition = s.ReturnDefinition
}).ToList();
});
// Compute the alarm-capable connection-name set so the semantic validator
// can gate native-alarm-source bindings. "Alarm-capable" matches the DCL
@@ -149,8 +180,13 @@ public class FlatteningPipeline : IFlatteningPipeline
// pre-loaded once into a name→JSON map here (avoiding sync-over-async) and the
// seam is a pure in-memory lookup. An unresolved {"$ref":"lib:Name"} in any
// validated script schema then becomes a deploy-blocking SchemaReference error.
// Session-global, same as the shared scripts above.
var schemaLibrary = await session.GetSchemaLibraryAsync(async () =>
{
var sharedSchemas = await _sharedSchemaRepo.ListAsync(cancellationToken);
var schemaLibrary = sharedSchemas.ToDictionary(s => s.Name, s => s.SchemaJson, StringComparer.Ordinal);
return (IReadOnlyDictionary<string, string>)sharedSchemas
.ToDictionary(s => s.Name, s => s.SchemaJson, StringComparer.Ordinal);
});
Func<string, string?> resolveSchemaRef = name => schemaLibrary.GetValueOrDefault(name);
// Validate. This is the deploy-gating path, so connection-binding completeness
@@ -180,24 +216,6 @@ public class FlatteningPipeline : IFlatteningPipeline
new FlatteningPipelineResult(config, hash, validation));
}
private async Task<IReadOnlyList<Commons.Entities.Templates.Template>> BuildTemplateChainAsync(
int templateId,
CancellationToken cancellationToken)
{
var chain = new List<Commons.Entities.Templates.Template>();
var currentId = (int?)templateId;
while (currentId.HasValue)
{
var template = await _templateRepo.GetTemplateWithChildrenAsync(currentId.Value, cancellationToken);
if (template == null) break;
chain.Add(template);
currentId = template.ParentTemplateId;
}
return chain;
}
private async Task<IReadOnlyDictionary<int, DataConnection>> LoadDataConnectionsAsync(
int siteId,
CancellationToken cancellationToken)
@@ -23,11 +23,29 @@ public interface IFlatteningPipeline
/// which need only the flattened config + revision hash, not a compile. Structural and
/// semantic validation still run in both cases.
/// </param>
/// <param name="session">
/// Optional flatten session memoising template-chain, composition and
/// session-global repository reads. Pass one shared session when flattening
/// several instances in a batch (see <c>DeploymentService.DeploySiteAsync</c>)
/// so a template chain shared by N instances is walked once. When
/// <see langword="null"/> the pipeline creates a private single-use session,
/// which still de-duplicates the repeated composed-chain loads inside one
/// instance's flatten. Additive parameter — existing callers are unaffected.
/// </param>
/// <returns>A task that resolves to the flattened configuration, revision hash, and validation result; or a failure result if flattening could not complete.</returns>
Task<Result<FlatteningPipelineResult>> FlattenAndValidateAsync(
int instanceId,
CancellationToken cancellationToken = default,
bool validateScripts = true);
bool validateScripts = true,
FlattenSession? session = null);
/// <summary>
/// Creates a fresh <see cref="FlattenSession"/> bound to this pipeline's
/// watermark, for a caller that wants to share one across a batch of
/// <see cref="FlattenAndValidateAsync"/> calls.
/// </summary>
/// <returns>A new, empty session.</returns>
FlattenSession CreateSession();
}
/// <summary>
@@ -1,7 +1,9 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
@@ -39,6 +41,12 @@ public static class ServiceCollectionExtensions
ServiceDescriptor.Singleton<IValidateOptions<DeploymentManagerOptions>, DeploymentManagerOptionsValidator>());
services.AddSingleton<OperationLockManager>();
// Template/instance version watermark backing the flatten-session cache key
// and the staleness fast path. TryAdd because AddConfigurationDatabase
// registers the same singleton — whichever composition root runs first wins
// and both observe one instance.
services.TryAddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
// Push-based deployment-status notification. Registered
// as a singleton so the scoped DeploymentService and the Central UI's
// scoped Blazor page component share one instance — both run in the
@@ -1,3 +1,5 @@
using System.Collections.Concurrent;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
@@ -10,21 +12,70 @@ namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
/// deployed snapshot to decide staleness. Hosted in DeploymentManager so the
/// Transport component (which references only Commons + TemplateEngine) can probe
/// staleness without taking a DeploymentManager project reference.
///
/// <para>
/// <b>Watermark fast path.</b> A bundle import probes EVERY instance derived from
/// an overwritten template, and the Deployments page sweeps the fleet — both pay a
/// full flatten per instance for an answer that is usually "unchanged". A probe
/// result is therefore memoised against the
/// <see cref="ITemplateGraphWatermark"/> reading that produced it: the instance's
/// own version, the graph's structure version (which covers chain MEMBERSHIP), and
/// the version of every template the flatten actually walked. When all of those are
/// unchanged the recorded hash is still current by construction and the flatten is
/// skipped.
/// </para>
///
/// <para>
/// The fast path can only ever cause EXTRA work, never stale work: the watermark is
/// in-memory and process-local, so a restart, a failover to the other central node,
/// or any mutation at all simply misses and falls back to the authoritative
/// flatten.
/// </para>
/// </summary>
public sealed class StaleInstanceProbe : IStaleInstanceProbe
{
/// <summary>
/// Ceiling on memoised probe results. Bounded because the key space is the
/// instance table and this cache is process-wide and static; on overflow it is
/// dropped wholesale, which is safe here (unlike the script-compile verdict
/// cache) because a miss costs one flatten and leaks nothing.
/// </summary>
private const int MaxCachedProbes = 4096;
private static readonly ConcurrentDictionary<int, ProbeMemo> Memos = new();
private readonly IFlatteningPipeline _flatteningPipeline;
private readonly ITemplateGraphWatermark _watermark;
/// <summary>Initializes a new <see cref="StaleInstanceProbe"/>.</summary>
/// <param name="flatteningPipeline">The deployment flattening pipeline used to recompute the current revision hash.</param>
public StaleInstanceProbe(IFlatteningPipeline flatteningPipeline)
/// <param name="watermark">Graph version watermark backing the fast path.</param>
public StaleInstanceProbe(IFlatteningPipeline flatteningPipeline, ITemplateGraphWatermark watermark)
{
_flatteningPipeline = flatteningPipeline ?? throw new ArgumentNullException(nameof(flatteningPipeline));
_watermark = watermark ?? throw new ArgumentNullException(nameof(watermark));
}
/// <summary>
/// A previously computed revision hash together with the exact watermark
/// readings it was computed under.
/// </summary>
/// <param name="Structure">Graph structure version at flatten time.</param>
/// <param name="InstanceVersion">The instance's own version at flatten time.</param>
/// <param name="TemplateVersions">Version of every template the flatten walked, by template id.</param>
/// <param name="RevisionHash">The hash that reading produced.</param>
private sealed record ProbeMemo(
long Structure,
long InstanceVersion,
IReadOnlyDictionary<int, long> TemplateVersions,
string RevisionHash);
/// <inheritdoc />
public async Task<string?> GetCurrentRevisionHashAsync(int instanceId, CancellationToken cancellationToken = default)
{
if (TryGetMemoisedHash(instanceId, out var memoised))
return memoised;
// The pipeline returns a Result; a flatten failure (e.g. unresolvable
// template chain mid-import) yields null so the caller treats the
// instance as "staleness indeterminate" and skips it. Flattening reuses
@@ -33,9 +84,63 @@ public sealed class StaleInstanceProbe : IStaleInstanceProbe
// Staleness only needs the revision hash — skip the expensive Roslyn
// script-compilation stage (this probe is called per-instance across a whole
// bundle import in BundleImporter.ComputeStaleInstanceIdsAsync).
//
// Watermark readings are taken BEFORE the flatten. A mutation that lands
// during the flatten therefore bumps a version past the recorded one and
// invalidates the memo on the next read, rather than being captured as if
// it had already been included.
var structure = _watermark.StructureVersion;
var instanceVersion = _watermark.GetInstanceVersion(instanceId);
var session = _flatteningPipeline.CreateSession();
var result = await _flatteningPipeline
.FlattenAndValidateAsync(instanceId, cancellationToken, validateScripts: false)
.FlattenAndValidateAsync(instanceId, cancellationToken, validateScripts: false, session)
.ConfigureAwait(false);
return result.IsSuccess ? result.Value.RevisionHash : null;
if (result.IsFailure)
return null;
if (Memos.Count >= MaxCachedProbes)
Memos.Clear();
var visited = session.VisitedTemplateIds;
var versions = new Dictionary<int, long>(visited.Count);
foreach (var templateId in visited)
versions[templateId] = _watermark.GetTemplateVersion(templateId);
Memos[instanceId] = new ProbeMemo(structure, instanceVersion, versions, result.Value.RevisionHash);
return result.Value.RevisionHash;
}
/// <summary>
/// Returns the memoised revision hash when every watermark reading it was
/// computed under is still current.
/// </summary>
private bool TryGetMemoisedHash(int instanceId, out string? hash)
{
hash = null;
if (!Memos.TryGetValue(instanceId, out var memo))
return false;
if (memo.Structure != _watermark.StructureVersion)
return false;
if (memo.InstanceVersion != _watermark.GetInstanceVersion(instanceId))
return false;
foreach (var (templateId, version) in memo.TemplateVersions)
{
if (_watermark.GetTemplateVersion(templateId) != version)
return false;
}
hash = memo.RevisionHash;
return true;
}
/// <summary>
/// Drops every memoised probe result. Test hook — production invalidation is
/// driven entirely by the watermark.
/// </summary>
public static void ClearMemos() => Memos.Clear();
}
@@ -266,7 +266,7 @@ public class ManagementActor : ReceiveActor
PreviewBundleCommand or ImportBundleCommand => AdminOnly,
// Deployer operations
CreateInstanceCommand or MgmtDeployInstanceCommand or MgmtEnableInstanceCommand
CreateInstanceCommand or MgmtDeployInstanceCommand or MgmtDeploySiteCommand or MgmtEnableInstanceCommand
or MgmtDisableInstanceCommand or MgmtDeleteInstanceCommand
or SetConnectionBindingsCommand or SetInstanceOverridesCommand or SetInstanceAreaCommand
or SetInstanceAlarmOverrideCommand or DeleteInstanceAlarmOverrideCommand
@@ -339,6 +339,7 @@ public class ManagementActor : ReceiveActor
GetInstanceCommand cmd => await HandleGetInstance(sp, cmd, user),
CreateInstanceCommand cmd => await HandleCreateInstance(sp, cmd, user),
MgmtDeployInstanceCommand cmd => await HandleDeployInstance(sp, cmd, user),
MgmtDeploySiteCommand cmd => await HandleDeploySite(sp, cmd, user),
MgmtEnableInstanceCommand cmd => await HandleEnableInstance(sp, cmd, user),
MgmtDisableInstanceCommand cmd => await HandleDisableInstance(sp, cmd, user),
MgmtDeleteInstanceCommand cmd => await HandleDeleteInstance(sp, cmd, user),
@@ -568,8 +569,15 @@ public class ManagementActor : ReceiveActor
private static async Task<object?> HandleListTemplates(IServiceProvider sp, ListTemplatesCommand cmd)
{
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
var templates = await repo.GetAllTemplatesAsync();
return Page(templates, cmd.Skip, cmd.Take);
// Paged and projected in the DATABASE. This used to load every template's
// full child graph — five Includes under AsSplitQuery, including every
// script body in the system — and then throw all but one page away in
// memory, so listing page 1 of 20 cost the same as listing everything.
// Child collections are reduced to counts, which is what every list
// surface (CLI table, UI tree) actually renders; a caller that needs a
// template's members already fetches it with GetTemplate.
return await repo.GetTemplateSummariesAsync(cmd.Skip, cmd.Take);
}
// Additive offset paging (arch-review P2). Take = null (or non-positive) keeps
@@ -830,6 +838,26 @@ public class ManagementActor : ReceiveActor
: throw new ManagementCommandException(result.Error);
}
/// <summary>
/// Bulk-deploys every deployable instance at a site.
///
/// <para>
/// Site scope is enforced on the SITE (not per instance) because the site is
/// the command's target: a site-scoped Deployer may bulk-deploy only sites in
/// their scope, and every instance the batch touches belongs to that site by
/// construction — so the per-instance check would be redundant.
/// </para>
/// </summary>
private static async Task<object?> HandleDeploySite(IServiceProvider sp, MgmtDeploySiteCommand cmd, AuthenticatedUser user)
{
EnforceSiteScope(user, cmd.SiteId);
var svc = sp.GetRequiredService<DeploymentService>();
var result = await svc.DeploySiteAsync(cmd.SiteId, user.Username);
return result.IsSuccess
? result.Value
: throw new ManagementCommandException(result.Error);
}
private static async Task<object?> HandleEnableInstance(IServiceProvider sp, MgmtEnableInstanceCommand cmd, AuthenticatedUser user)
{
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
@@ -900,14 +928,58 @@ public class ManagementActor : ReceiveActor
$"Attribute '{attrName}' is locked and cannot be overridden. No overrides were applied.");
}
var svc = sp.GetRequiredService<InstanceService>();
var results = new List<InstanceAttributeOverride>();
// Apply as ONE read + ONE commit. The previous loop called
// InstanceService.SetAttributeOverrideAsync per entry, and each of those
// re-read the instance, re-read the template's attributes, re-read the
// instance's existing overrides, then committed and wrote an audit row —
// K read-modify-commit cycles for what is logically one batch. That is
// both K× the round trips and K× the windows in which a mid-batch fault
// can leave the instance partially mutated, which is exactly what the
// pre-validation above was bolted on to mitigate.
//
// Now: the existing override rows are read once in bulk, every entry is
// added or updated against that snapshot, and a single SaveChangesAsync
// commits the whole batch — making the all-or-nothing promise real rather
// than best-effort. One audit row summarises the batch.
var existingOverrides = await repo.GetOverridesByInstanceIdAsync(cmd.InstanceId);
var existingByName = existingOverrides
.GroupBy(o => o.AttributeName, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
var results = new List<InstanceAttributeOverride>(cmd.Overrides.Count);
foreach (var (attrName, overrideValue) in cmd.Overrides)
{
var result = await svc.SetAttributeOverrideAsync(cmd.InstanceId, attrName, overrideValue, user.Username);
if (!result.IsSuccess) throw new ManagementCommandException(result.Error);
results.Add(result.Value);
// attrsByName lookup is guaranteed to hit — the pre-validation loop
// above rejects the whole batch on an unknown or locked attribute.
var elementDataType = attrsByName[attrName].ElementDataType;
if (existingByName.TryGetValue(attrName, out var existing))
{
existing.OverrideValue = overrideValue;
existing.ElementDataType = elementDataType;
await repo.UpdateInstanceAttributeOverrideAsync(existing);
results.Add(existing);
}
else
{
var created = new InstanceAttributeOverride(attrName)
{
InstanceId = cmd.InstanceId,
OverrideValue = overrideValue,
ElementDataType = elementDataType
};
await repo.AddInstanceAttributeOverrideAsync(created);
results.Add(created);
}
}
await repo.SaveChangesAsync();
var auditService = sp.GetRequiredService<IAuditService>();
await auditService.LogAsync(
user.Username, "SetOverrides", "Instance", cmd.InstanceId.ToString(), instance.UniqueName,
new { Count = results.Count, Attributes = cmd.Overrides.Keys.ToArray() });
return results;
}
@@ -2439,49 +2511,67 @@ public class ManagementActor : ReceiveActor
{
var repo = sp.GetRequiredService<IDeploymentManagerRepository>();
// Opportunistic retention sweep for terminal records. Rate-limited
// process-wide and best-effort inside the service, so it costs this query
// nothing on all but the first call in a retention interval — and it is
// deliberately on the LIST path, the operation that degrades when the
// insert-only DeploymentRecords table is allowed to grow unbounded.
//
// GetService, not GetRequiredService: retention maintenance is a
// side-benefit of this handler, not a precondition of answering the query.
// A composition root that wires the management surface without the
// deployment services (as the actor's own tests do) must still be able to
// list deployments.
if (sp.GetService<DeploymentService>() is { } deploymentService)
await deploymentService.TryPurgeTerminalDeploymentRecordsAsync();
// Page and Status are honoured DB-side. Both arrived on the command
// contract (and the CLI has been sending --page / --page-size / --status
// all along) but the handler previously ignored all three and returned the
// whole table.
var status = ParseDeploymentStatus(cmd.Status);
var skip = Math.Max(0, (Math.Max(1, cmd.Page) - 1) * Math.Max(1, cmd.PageSize));
var take = cmd.PageSize > 0 ? cmd.PageSize : (int?)null;
// Instance-scoped query: enforce against the target instance's site
// before reading its deployment history.
if (cmd.InstanceId.HasValue)
{
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId.Value);
return await repo.GetDeploymentsByInstanceIdAsync(cmd.InstanceId.Value);
return await repo.QueryDeploymentSummariesAsync(
cmd.InstanceId.Value, status, instanceIdScope: null, skip, take);
}
// Unfiltered query: a site-scoped Deployment user must only see records
// for instances at sites within their scope. DeploymentRecord has no
// SiteId, so resolve each record's instance to its site and filter
// (mirrors the HandleListInstances / HandleListSites filter pattern).
var records = await repo.GetAllDeploymentRecordsAsync();
// SiteId, so the scope is resolved to the set of in-scope instance ids and
// pushed into the query as an id filter — rather than loading every record
// AND every instance and intersecting them in memory, which made an
// unfiltered listing cost two full-table loads regardless of page size.
if (user.PermittedSiteIds.Length == 0 || user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
return records;
return await repo.QueryDeploymentSummariesAsync(null, status, instanceIdScope: null, skip, take);
var permittedIds = new HashSet<string>(user.PermittedSiteIds);
var templateRepo = sp.GetRequiredService<ITemplateEngineRepository>();
// 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 scopedInstanceIds = allInstances
.Where(i => permittedIds.Contains(i.SiteId.ToString()))
.Select(i => i.Id)
.ToArray();
return await repo.QueryDeploymentSummariesAsync(null, status, scopedInstanceIds, skip, take);
}
var scoped = new List<DeploymentRecord>();
foreach (var record in records)
{
if (instanceSiteLookup.TryGetValue(record.InstanceId, out var siteId)
&& siteId.HasValue
&& permittedIds.Contains(siteId.Value.ToString()))
{
scoped.Add(record);
}
}
return scoped;
}
/// <summary>
/// Parses the wire <c>Status</c> filter into a <see cref="DeploymentStatus"/>.
/// An unparseable value is treated as "no filter" rather than an error, which
/// preserves the previous handler's behaviour of ignoring the field entirely.
/// </summary>
private static DeploymentStatus? ParseDeploymentStatus(string? status) =>
!string.IsNullOrWhiteSpace(status) && Enum.TryParse<DeploymentStatus>(status, ignoreCase: true, out var parsed)
? parsed
: null;
// ========================================================================
// Audit Log handler
@@ -42,6 +42,10 @@ public static class ManagementEndpoints
[
typeof(ImportBundleCommand), typeof(PreviewBundleCommand), typeof(ExportBundleCommand),
typeof(MgmtDeployArtifactsCommand), typeof(MgmtDeployInstanceCommand),
// A bulk site deploy is by construction the longest command in the set —
// it is N instance deploys, so it must never inherit the ordinary Ask
// timeout or the caller would 504 while the batch is still applying.
typeof(MgmtDeploySiteCommand),
];
/// <summary>
@@ -218,7 +218,7 @@ public class TemplateService
// only invariant the move can break is two templates sharing a
// (FolderId, Name) at the destination, which the design's
// naming-collisions-are-design-time-errors rule forbids.
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
var collision = allTemplates.FirstOrDefault(t =>
t.Id != templateId &&
t.FolderId == newFolderId &&
@@ -300,7 +300,7 @@ public class TemplateService
attribute.TemplateId = templateId;
// If inheriting, validate not trying to add a member that would collide
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
var testTemplate = CloneTemplateWithNewAttribute(template, attribute);
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
if (collisions.Count > 0)
@@ -352,7 +352,7 @@ public class TemplateService
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
if (template?.ParentTemplateId != null)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
var parentMember = TemplateResolver.FindMemberByCanonicalName(
existing.Name, template.ParentTemplateId.Value, allTemplates);
if (parentMember != null && parentMember.IsLocked)
@@ -436,7 +436,7 @@ public class TemplateService
var template = await _repository.GetTemplateByIdAsync(attribute.TemplateId, cancellationToken);
if (template?.ParentTemplateId != null)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
var parentMember = TemplateResolver.FindMemberByCanonicalName(
attribute.Name, template.ParentTemplateId.Value, allTemplates);
if (parentMember != null)
@@ -533,7 +533,7 @@ public class TemplateService
alarm.TemplateId = templateId;
// Check collisions
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
var testTemplate = CloneTemplateWithNewAlarm(template, alarm);
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
if (collisions.Count > 0)
@@ -583,7 +583,7 @@ public class TemplateService
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
if (template?.ParentTemplateId != null)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
var parentMember = TemplateResolver.FindMemberByCanonicalName(
existing.Name, template.ParentTemplateId.Value, allTemplates);
if (parentMember != null && parentMember.IsLocked)
@@ -656,7 +656,7 @@ public class TemplateService
var template = await _repository.GetTemplateByIdAsync(alarm.TemplateId, cancellationToken);
if (template?.ParentTemplateId != null)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
var parentMember = TemplateResolver.FindMemberByCanonicalName(
alarm.Name, template.ParentTemplateId.Value, allTemplates);
if (parentMember != null)
@@ -703,7 +703,7 @@ public class TemplateService
script.TemplateId = templateId;
// Check collisions
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
var testTemplate = CloneTemplateWithNewScript(template, script);
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
if (collisions.Count > 0)
@@ -750,7 +750,7 @@ public class TemplateService
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
if (template?.ParentTemplateId != null)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
var parentMember = TemplateResolver.FindMemberByCanonicalName(
existing.Name, template.ParentTemplateId.Value, allTemplates);
if (parentMember != null && parentMember.IsLocked)
@@ -827,7 +827,7 @@ public class TemplateService
var template = await _repository.GetTemplateByIdAsync(script.TemplateId, cancellationToken);
if (template?.ParentTemplateId != null)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
var parentMember = TemplateResolver.FindMemberByCanonicalName(
script.Name, template.ParentTemplateId.Value, allTemplates);
if (parentMember != null)
@@ -888,7 +888,7 @@ public class TemplateService
// Acyclicity is checked against the base, not the to-be-created derived template —
// the derived inherits from the base, so a base→base cycle is the meaningful check.
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
var cycleError = CycleDetector.DetectCompositionCycle(templateId, composedTemplateId, allTemplates);
if (cycleError != null)
return Result<TemplateComposition>.Failure(cycleError);
@@ -1226,19 +1226,34 @@ public class TemplateService
/// Propagates a base member change to every derived descendant by reconciling
/// their inherited rows. Called automatically after a base member is added,
/// updated, or deleted. The base template itself is NOT reconciled — it is the
/// author of the change. A no-op (and a single cheap query) when the template
/// has no descendants, which is the common case.
/// author of the change. A no-op when the template has no descendants, which is
/// the common case.
/// </summary>
/// <param name="templateId">The template whose member set changed.</param>
/// <param name="user">Username for the audit row.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <param name="loadedTemplates">
/// The full, TRACKED template graph if the caller already has one. Reconciling
/// writes through these entities and compares script bodies, so this must be
/// the tracked <c>GetAllTemplatesAsync</c> shape — never the no-tracking,
/// body-free analysis projection. When <see langword="null"/> the graph is
/// loaded here.
///
/// <para>
/// Passing it matters because every member Add/Update/Delete on
/// <see cref="TemplateService"/> used to load the whole graph once for its own
/// checks and then again inside this method. Handing the graph over collapses
/// that to one load per operation.
/// </para>
/// </param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task ReconcileDescendantsAsync(
int templateId,
string user,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
IReadOnlyList<Template>? loadedTemplates = null)
{
var all = await _repository.GetAllTemplatesAsync(cancellationToken);
var all = loadedTemplates ?? await _repository.GetAllTemplatesAsync(cancellationToken);
var descendants = GetDescendantIdsBreadthFirst(templateId, all);
if (descendants.Count == 0)
return;
@@ -1591,7 +1606,7 @@ public class TemplateService
int templateId,
CancellationToken cancellationToken = default)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
return TemplateResolver.ResolveAllMembers(templateId, allTemplates);
}
@@ -1616,7 +1631,7 @@ public class TemplateService
if (template == null)
return Result<bool>.Failure($"Template with ID {templateId} not found.");
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
var members = TemplateResolver.ResolveAllMembers(templateId, allTemplates);
var member = members.FirstOrDefault(m => m.CanonicalName == canonicalName);
@@ -1647,7 +1662,7 @@ public class TemplateService
if (template == null)
return new[] { $"Template with ID {templateId} not found." };
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
return CollisionDetector.DetectCollisions(template, allTemplates);
}
@@ -1669,7 +1684,7 @@ public class TemplateService
int? proposedComposedTemplateId,
CancellationToken cancellationToken = default)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
if (proposedParentId.HasValue)
{
@@ -1698,7 +1713,7 @@ public class TemplateService
private async Task<string?> ValidateCollisionsAsync(Template template, CancellationToken cancellationToken)
{
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
var collisions = CollisionDetector.DetectCollisions(template, allTemplates);
if (collisions.Count > 0)
return string.Join(" ", collisions);
@@ -30,25 +30,53 @@ namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
/// </para>
///
/// <para>
/// Bounded at <see cref="MaxEntries"/> entries; on overflow the cache is simply
/// cleared (the verdict is cheap to recompute, so a coarse reset is safe and avoids
/// eviction bookkeeping). <see cref="Hits"/>/<see cref="Count"/>/<see cref="Clear"/>
/// are exposed for tests and diagnostics.
/// <b>Eviction is segmented, never wholesale.</b> Overflow used to
/// <c>Clear()</c> the whole cache, which reads as harmless ("the verdict is cheap
/// to recompute") but is not: a recompute is a fresh Roslyn compile, and every
/// script compile loads an assembly through a NON-COLLECTIBLE
/// <c>InteractiveAssemblyLoader</c>. That leak is the entire reason this cache
/// exists, so dropping every hot entry at the 4096th distinct script re-opens it
/// for the whole working set at once. Instead the cache runs two generations: new
/// verdicts land in <c>hot</c>, a hit in <c>cold</c> is promoted back into
/// <c>hot</c>, and on overflow <c>hot</c> becomes the new <c>cold</c> and the old
/// <c>cold</c> — the half nothing has touched for a full generation — is the only
/// thing dropped. Anything actively in use is therefore retained across an
/// eviction, and the cache still cannot grow past two generations.
/// </para>
/// </summary>
public static class ScriptCompileVerdictCache
{
/// <summary>Upper bound on cached entries; the cache is cleared wholesale on overflow.</summary>
private const int MaxEntries = 4096;
/// <summary>
/// Upper bound on entries in the hot generation. The cache holds at most
/// <c>2 × SegmentCapacity</c> entries in total (hot + cold), preserving the
/// previous 4096-entry ceiling.
/// </summary>
private const int SegmentCapacity = 2048;
private static readonly ConcurrentDictionary<string, (bool Ok, string? Error)> Cache = new();
/// <summary>
/// Guards the generation ROTATION only. Reads and single-entry writes stay on
/// the lock-free <see cref="ConcurrentDictionary{TKey,TValue}"/> paths; the
/// lock exists so two threads overflowing at once cannot rotate twice and
/// discard a generation that was only one insert old.
/// </summary>
private static readonly object RotateGate = new();
private static ConcurrentDictionary<string, (bool Ok, string? Error)> _hot = new();
private static ConcurrentDictionary<string, (bool Ok, string? Error)> _cold = new();
private static long _hits;
private static long _evictions;
/// <summary>Number of cache hits observed since the last <see cref="Clear"/>.</summary>
public static long Hits => Interlocked.Read(ref _hits);
/// <summary>Current number of cached verdicts.</summary>
public static int Count => Cache.Count;
/// <summary>
/// Number of generation rotations performed. One rotation drops at most
/// <see cref="SegmentCapacity"/> untouched entries. Exposed for diagnostics.
/// </summary>
public static long Evictions => Interlocked.Read(ref _evictions);
/// <summary>Current number of cached verdicts across both generations.</summary>
public static int Count => _hot.Count + _cold.Count;
/// <summary>
/// Returns the cached verdict for the pair (<paramref name="surface"/>,
@@ -68,26 +96,67 @@ public static class ScriptCompileVerdictCache
{
var key = surface + ":" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(code)));
if (Cache.TryGetValue(key, out var verdict))
// Snapshot the generations once: a concurrent rotation between the two
// lookups would otherwise let an entry slip through both.
var hot = _hot;
var cold = _cold;
if (hot.TryGetValue(key, out var verdict))
{
Interlocked.Increment(ref _hits);
return verdict;
}
verdict = factory();
// Coarse bound: on overflow drop everything rather than track evictions.
if (Cache.Count >= MaxEntries)
Cache.Clear();
Cache[key] = verdict;
if (cold.TryGetValue(key, out verdict))
{
// Promote: the entry is in active use, so it must survive the next
// rotation. Writing to the CURRENT hot generation (re-read, in case a
// rotation happened since the snapshot) is what makes this an LRU
// rather than a fixed-lifetime cache.
Interlocked.Increment(ref _hits);
_hot[key] = verdict;
return verdict;
}
/// <summary>Clears all cached verdicts and resets the hit counter.</summary>
verdict = factory();
Store(key, verdict);
return verdict;
}
/// <summary>
/// Inserts a freshly computed verdict, rotating the generations first when the
/// hot segment is full.
/// </summary>
private static void Store(string key, (bool Ok, string? Error) verdict)
{
if (_hot.Count >= SegmentCapacity)
{
lock (RotateGate)
{
// Re-check inside the lock: a racing thread may already have
// rotated, in which case rotating again would discard a
// generation that is one insert old.
if (_hot.Count >= SegmentCapacity)
{
_cold = _hot;
_hot = new ConcurrentDictionary<string, (bool Ok, string? Error)>();
Interlocked.Increment(ref _evictions);
}
}
}
_hot[key] = verdict;
}
/// <summary>Clears all cached verdicts and resets the counters. Test/diagnostic use only.</summary>
public static void Clear()
{
Cache.Clear();
lock (RotateGate)
{
_hot = new ConcurrentDictionary<string, (bool Ok, string? Error)>();
_cold = new ConcurrentDictionary<string, (bool Ok, string? Error)>();
}
Interlocked.Exchange(ref _hits, 0);
Interlocked.Exchange(ref _evictions, 0);
}
}
@@ -141,6 +141,22 @@ public class CommandTreeTests
Assert.Contains("remove", subNames);
}
[Fact]
public void Deploy_HasSiteVerb_WithRequiredSiteId()
{
// WP2.5: bulk site deploy. Named `deploy site` to sit alongside
// `deploy instance` / `deploy artifacts` — the group's convention is that
// the verb names the SCOPE of the deploy.
var deploy = DeployCommands.Build(Url, Format, Username, Password);
var site = deploy.Subcommands.Single(c => c.Name == "site");
// --site-id is REQUIRED here, unlike `deploy artifacts` where omitting it
// means fleet-wide. A bulk instance deploy must never be accidentally
// fleet-wide.
Assert.NotEmpty(site.Parse([]).Errors);
Assert.Empty(site.Parse(["--site-id", "3"]).Errors);
}
[Fact]
public void InstanceNativeAlarmSource_HasSetAndClear()
{
@@ -323,6 +339,7 @@ public class CommandTreeTests
[InlineData(typeof(SetInstanceOverridesCommand))]
[InlineData(typeof(DebugSnapshotCommand))]
[InlineData(typeof(MgmtDeployInstanceCommand))]
[InlineData(typeof(MgmtDeploySiteCommand))]
[InlineData(typeof(QueryAuditLogCommand))]
[InlineData(typeof(ExportBundleCommand))]
[InlineData(typeof(PreviewBundleCommand))]
@@ -8,6 +8,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
@@ -120,7 +121,7 @@ public class SplitQueryBehaviourTests : IDisposable
public SplitQueryBehaviourTests()
{
_context = SqliteTestHelper.CreateInMemoryContext();
_repository = new TemplateEngineRepository(_context);
_repository = new TemplateEngineRepository(_context, new TemplateGraphWatermark());
}
public void Dispose()
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
@@ -19,7 +20,7 @@ public class TemplateEngineRepositoryTests : IDisposable
_context = new ScadaBridgeDbContext(options);
_context.Database.OpenConnection();
_context.Database.EnsureCreated();
_repository = new TemplateEngineRepository(_context);
_repository = new TemplateEngineRepository(_context, new TemplateGraphWatermark());
}
public void Dispose()
@@ -0,0 +1,279 @@
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
/// <summary>
/// WP2.5: bulk site deployment. Pins the properties that make the fan-out safe —
/// concurrency is BOUNDED by the configured degree, one slow instance does not stop
/// the others completing, a per-instance timeout contains a wedged instance, and
/// every instance still gets its own deployment id and its own operation lock.
/// </summary>
public class DeploySiteAsyncTests : TestKit
{
private const int SiteId = 1;
private readonly IDeploymentManagerRepository _repo = Substitute.For<IDeploymentManagerRepository>();
private readonly IFlatteningPipeline _pipeline = Substitute.For<IFlatteningPipeline>();
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private readonly IAuditService _audit = Substitute.For<IAuditService>();
private readonly OperationLockManager _lockManager = new();
public DeploySiteAsyncTests()
{
_siteRepo.GetSiteByIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci => new Site($"Site {ci.ArgAt<int>(0)}", $"site-{ci.ArgAt<int>(0)}") { Id = ci.ArgAt<int>(0) });
_pipeline.CreateSession().Returns(_ => new FlattenSession(new TemplateGraphWatermark()));
}
/// <summary>Seeds <paramref name="count"/> deployable instances at the test site.</summary>
private List<Instance> ArrangeInstances(int count)
{
var instances = new List<Instance>();
for (var i = 1; i <= count; i++)
{
var instance = new Instance($"Inst-{i:00}")
{
Id = i,
SiteId = SiteId,
TemplateId = 10,
State = InstanceState.NotDeployed
};
instances.Add(instance);
_repo.GetInstanceByIdAsync(i, Arg.Any<CancellationToken>()).Returns(instance);
_repo.GetCurrentDeploymentStatusAsync(i, Arg.Any<CancellationToken>()).Returns((DeploymentRecord?)null);
var config = new FlattenedConfiguration { InstanceUniqueName = instance.UniqueName };
_pipeline.FlattenAndValidateAsync(
i, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>())
.Returns(Result<FlatteningPipelineResult>.Success(
new FlatteningPipelineResult(config, $"sha256:{i}", ValidationResult.Success())));
}
_siteRepo.GetInstancesBySiteIdAsync(SiteId, Arg.Any<CancellationToken>()).Returns(instances);
return instances;
}
private DeploymentService CreateService(IActorRef commActor, int maxParallelism, TimeSpan? perInstanceTimeout = null)
{
var comms = new CommunicationService(
Options.Create(new CommunicationOptions { DeploymentTimeout = TimeSpan.FromSeconds(30) }),
NullLogger<CommunicationService>.Instance);
comms.SetCommunicationActor(commActor);
var options = Options.Create(new DeploymentManagerOptions
{
OperationLockTimeout = TimeSpan.FromSeconds(5),
SiteDeploymentMaxParallelism = maxParallelism,
SiteDeploymentTimeoutPerInstance = perInstanceTimeout ?? TimeSpan.FromSeconds(30)
});
return new DeploymentService(
_repo, _siteRepo, _pipeline, comms, _lockManager, _audit,
new DiffService(),
new RevisionHashService(),
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
options,
Options.Create(new CommunicationOptions
{
CentralFetchBaseUrl = "https://central.test:9000",
PendingDeploymentTtl = TimeSpan.FromMinutes(5)
}),
NullLogger<DeploymentService>.Instance);
}
[Fact]
public async Task DeploySiteAsync_SlowInstance_OthersStillComplete_AndConcurrencyIsBounded()
{
const int instanceCount = 8;
const int maxParallelism = 3;
ArrangeInstances(instanceCount);
var tracker = new ConcurrencyTracker();
var commActor = Sys.ActorOf(Props.Create(() =>
new ThrottledSiteActor(tracker, slowInstanceName: "Inst-01", slowDelay: TimeSpan.FromMilliseconds(400))));
var service = CreateService(commActor, maxParallelism);
var result = await service.DeploySiteAsync(SiteId, "admin");
Assert.True(result.IsSuccess);
var summary = result.Value;
// Every instance produced a row and all eight completed, including the
// seven that were NOT waiting on the slow site round-trip.
Assert.Equal(instanceCount, summary.InstanceResults.Count);
Assert.Equal(instanceCount, summary.SuccessCount);
Assert.Equal(0, summary.FailureCount);
// The fan-out never exceeded the configured bound.
Assert.True(tracker.MaxObserved <= maxParallelism,
$"observed {tracker.MaxObserved} concurrent site round-trips, bound was {maxParallelism}");
// ...and it genuinely WAS concurrent, so the assertion above is not
// vacuously satisfied by a serial implementation.
Assert.True(tracker.MaxObserved > 1, "site round-trips did not run concurrently at all");
// Deployment identity: one distinct deployment id per instance.
var deploymentIds = summary.InstanceResults.Select(r => r.DeploymentId).ToList();
Assert.Equal(instanceCount, deploymentIds.Distinct().Count());
Assert.DoesNotContain(deploymentIds, id => string.IsNullOrEmpty(id));
}
[Fact]
public async Task DeploySiteAsync_WedgedInstance_TimesOutAlone_RestSucceed()
{
ArrangeInstances(4);
var tracker = new ConcurrencyTracker();
// Instance 2's round-trip is never answered — its per-instance deadline
// must contain it rather than stalling the batch.
var commActor = Sys.ActorOf(Props.Create(() =>
new ThrottledSiteActor(tracker, slowInstanceName: "Inst-02", slowDelay: Timeout.InfiniteTimeSpan)));
var service = CreateService(commActor, maxParallelism: 4, perInstanceTimeout: TimeSpan.FromMilliseconds(300));
var result = await service.DeploySiteAsync(SiteId, "admin");
Assert.True(result.IsSuccess);
var summary = result.Value;
Assert.Equal(4, summary.InstanceResults.Count);
Assert.Equal(3, summary.SuccessCount);
Assert.Equal(1, summary.FailureCount);
var wedged = Assert.Single(summary.InstanceResults, r => !r.Success);
Assert.Equal("Inst-02", wedged.UniqueName);
}
[Fact]
public async Task DeploySiteAsync_ReleasesEveryOperationLock()
{
ArrangeInstances(5);
var commActor = Sys.ActorOf(Props.Create(() =>
new ThrottledSiteActor(new ConcurrencyTracker(), slowInstanceName: null, slowDelay: TimeSpan.Zero)));
var service = CreateService(commActor, maxParallelism: 2);
await service.DeploySiteAsync(SiteId, "admin");
// The per-instance operation lock is held from prepare through finalize;
// once the batch is done every entry must be reclaimed, or a second bulk
// deploy of the same site would deadlock on its own leftovers.
Assert.Equal(0, _lockManager.TrackedLockCount);
}
[Fact]
public async Task DeploySiteAsync_SiteWithNoInstances_SucceedsWithEmptySummary()
{
_siteRepo.GetInstancesBySiteIdAsync(SiteId, Arg.Any<CancellationToken>()).Returns([]);
var commActor = Sys.ActorOf(Props.Create(() =>
new ThrottledSiteActor(new ConcurrencyTracker(), slowInstanceName: null, slowDelay: TimeSpan.Zero)));
var result = await CreateService(commActor, maxParallelism: 2).DeploySiteAsync(SiteId, "admin");
Assert.True(result.IsSuccess);
Assert.Empty(result.Value.InstanceResults);
}
[Fact]
public async Task DeploySiteAsync_UnknownSite_ReturnsFailure()
{
_siteRepo.GetSiteByIdAsync(99, Arg.Any<CancellationToken>()).Returns((Site?)null);
var commActor = Sys.ActorOf(Props.Create(() =>
new ThrottledSiteActor(new ConcurrencyTracker(), slowInstanceName: null, slowDelay: TimeSpan.Zero)));
var result = await CreateService(commActor, maxParallelism: 2).DeploySiteAsync(99, "admin");
Assert.True(result.IsFailure);
Assert.Contains("not found", result.Error);
}
/// <summary>Records the peak number of simultaneously in-flight site round-trips.</summary>
private sealed class ConcurrencyTracker
{
private int _current;
private int _max;
/// <summary>Highest simultaneous in-flight count observed.</summary>
public int MaxObserved => Volatile.Read(ref _max);
/// <summary>Marks one round-trip as started and updates the peak.</summary>
public void Enter()
{
var now = Interlocked.Increment(ref _current);
int observed;
while (now > (observed = Volatile.Read(ref _max)))
{
if (Interlocked.CompareExchange(ref _max, now, observed) == observed)
break;
}
}
/// <summary>Marks one round-trip as finished.</summary>
public void Exit() => Interlocked.Decrement(ref _current);
}
/// <summary>
/// Stand-in site that answers <c>RefreshDeploymentCommand</c> with Success,
/// optionally delaying (or never answering) one named instance so the test can
/// observe the fan-out's bound and its per-instance deadline.
///
/// <para>
/// The reply is scheduled rather than sent inline, so the actor's mailbox is
/// not the thing serialising the batch — otherwise the concurrency the test is
/// measuring would be an artefact of the harness.
/// </para>
/// </summary>
private sealed class ThrottledSiteActor : ReceiveActor
{
public ThrottledSiteActor(ConcurrencyTracker tracker, string? slowInstanceName, TimeSpan slowDelay)
{
Receive<SiteEnvelope>(env =>
{
if (env.Message is not RefreshDeploymentCommand cmd)
return;
var replyTo = Sender;
var isSlow = slowInstanceName != null && cmd.InstanceUniqueName == slowInstanceName;
if (isSlow && slowDelay == Timeout.InfiniteTimeSpan)
{
// Never answer: the caller's per-instance deadline must fire.
tracker.Enter();
return;
}
var delay = isSlow ? slowDelay : TimeSpan.FromMilliseconds(60);
tracker.Enter();
Context.System.Scheduler.Advanced.ScheduleOnce(delay, () =>
{
tracker.Exit();
replyTo.Tell(new DeploymentStatusResponse(
cmd.DeploymentId, cmd.InstanceUniqueName,
DeploymentStatus.Success, null, DateTimeOffset.UtcNow));
});
});
}
}
}
@@ -6,6 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
@@ -75,7 +76,8 @@ public class DeploymentComparisonTests
new FlatteningService(),
new ValidationService(),
new RevisionHashService(),
sharedSchemaRepo);
sharedSchemaRepo,
new TemplateGraphWatermark());
}
private static (ITemplateEngineRepository, ISiteRepository, ISharedSchemaRepository) ArrangeNonCompilingScript()
@@ -17,6 +17,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
@@ -641,15 +642,21 @@ public class DeploymentServiceTests : TestKit
public async Task StaleInstanceProbe_RequestsValidateScriptsFalse()
{
// The Transport stale-instance probe also only needs the hash — same skip.
_pipeline.FlattenAndValidateAsync(5, Arg.Any<CancellationToken>(), Arg.Any<bool>())
// The probe memoises per-instance against the graph watermark, so start
// from a clean memo or a sibling test's entry would satisfy the probe
// without a flatten at all.
StaleInstanceProbe.ClearMemos();
_pipeline.CreateSession().Returns(_ => new FlattenSession(new TemplateGraphWatermark()));
_pipeline.FlattenAndValidateAsync(5, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>())
.Returns(Result<FlatteningPipelineResult>.Success(
new FlatteningPipelineResult(new FlattenedConfiguration(), "sha256:probe", ValidationResult.Success())));
var probe = new StaleInstanceProbe(_pipeline);
var probe = new StaleInstanceProbe(_pipeline, new TemplateGraphWatermark());
var hash = await probe.GetCurrentRevisionHashAsync(5);
Assert.Equal("sha256:probe", hash);
await _pipeline.Received(1).FlattenAndValidateAsync(5, Arg.Any<CancellationToken>(), false);
await _pipeline.Received(1).FlattenAndValidateAsync(5, Arg.Any<CancellationToken>(), false, Arg.Any<FlattenSession?>());
}
// ── DeploymentManager-007: comparison must produce a structured diff ──
@@ -0,0 +1,153 @@
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
using Template = ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template;
using TemplateAttribute = ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAttribute;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
/// <summary>
/// WP2.5: the flatten-session cache. Pins the two properties the bulk deploy path
/// depends on — that N instances sharing a template chain load that chain ONCE per
/// session, and that a template mutation (observed through the watermark)
/// invalidates the memo so a session can never serve stale template state.
/// </summary>
public class FlattenSessionCacheTests
{
private const int TemplateId = 10;
private const int ParentTemplateId = 9;
private const int SiteId = 100;
private readonly ITemplateEngineRepository _templateRepo = Substitute.For<ITemplateEngineRepository>();
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private readonly ISharedSchemaRepository _sharedSchemaRepo = Substitute.For<ISharedSchemaRepository>();
private readonly TemplateGraphWatermark _watermark = new();
private readonly FlatteningPipeline _sut;
public FlattenSessionCacheTests()
{
_sharedSchemaRepo.ListAsync(Arg.Any<CancellationToken>()).Returns([]);
_templateRepo.GetAllSharedScriptsAsync(Arg.Any<CancellationToken>()).Returns([]);
_siteRepo.GetDataConnectionsBySiteIdAsync(SiteId, Arg.Any<CancellationToken>()).Returns([]);
_sut = new FlatteningPipeline(
_templateRepo,
_siteRepo,
new FlatteningService(),
new ValidationService(),
new RevisionHashService(),
_sharedSchemaRepo,
_watermark);
}
/// <summary>
/// Seeds a two-link inheritance chain (Tank -> TankBase) and
/// <paramref name="instanceCount"/> instances that all derive from it.
/// </summary>
private void ArrangeSharedChain(int instanceCount)
{
var parent = new Template("TankBase") { Id = ParentTemplateId };
parent.Attributes.Add(new TemplateAttribute("Serial") { DataType = DataType.String, Value = "x" });
var template = new Template("Tank") { Id = TemplateId, ParentTemplateId = ParentTemplateId };
template.Attributes.Add(new TemplateAttribute("Temp") { DataType = DataType.Double, Value = "0" });
_templateRepo.GetTemplateWithChildrenAsync(TemplateId, Arg.Any<CancellationToken>()).Returns(template);
_templateRepo.GetTemplateWithChildrenAsync(ParentTemplateId, Arg.Any<CancellationToken>()).Returns(parent);
_templateRepo.GetCompositionsByTemplateIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>()).Returns([]);
for (var i = 1; i <= instanceCount; i++)
{
var instance = new Instance($"Tank-{i:00}") { Id = i, TemplateId = TemplateId, SiteId = SiteId };
_templateRepo.GetInstanceByIdAsync(i, Arg.Any<CancellationToken>()).Returns(instance);
}
}
[Fact]
public async Task SharedSession_TenInstancesOneTemplate_LoadsEachTemplateOnce()
{
const int instanceCount = 10;
ArrangeSharedChain(instanceCount);
var session = _sut.CreateSession();
for (var i = 1; i <= instanceCount; i++)
{
var result = await _sut.FlattenAndValidateAsync(i, CancellationToken.None, validateScripts: false, session);
Assert.True(result.IsSuccess);
}
// Two templates in the chain, each read exactly once across ten flattens.
await _templateRepo.Received(1).GetTemplateWithChildrenAsync(TemplateId, Arg.Any<CancellationToken>());
await _templateRepo.Received(1).GetTemplateWithChildrenAsync(ParentTemplateId, Arg.Any<CancellationToken>());
// The chain walk itself ran once, not once per instance.
Assert.Equal(1, session.ChainLoads);
Assert.Equal(2, session.TemplateLoads);
// The session-global queries were hoisted out of the per-instance loop:
// shared scripts + schema library once each, site connections once.
await _templateRepo.Received(1).GetAllSharedScriptsAsync(Arg.Any<CancellationToken>());
await _sharedSchemaRepo.Received(1).ListAsync(Arg.Any<CancellationToken>());
await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(SiteId, Arg.Any<CancellationToken>());
}
[Fact]
public async Task NoSharedSession_EveryInstanceReloadsTheChain()
{
const int instanceCount = 4;
ArrangeSharedChain(instanceCount);
// Baseline: without a shared session each flatten gets its own private
// one, which is the pre-WP2.5 behaviour the batch path improves on.
for (var i = 1; i <= instanceCount; i++)
{
var result = await _sut.FlattenAndValidateAsync(i, CancellationToken.None, validateScripts: false);
Assert.True(result.IsSuccess);
}
await _templateRepo.Received(instanceCount)
.GetTemplateWithChildrenAsync(TemplateId, Arg.Any<CancellationToken>());
}
[Fact]
public async Task TemplateMutation_InvalidatesMemo_SoSessionNeverServesStaleState()
{
ArrangeSharedChain(instanceCount: 2);
var session = _sut.CreateSession();
Assert.True((await _sut.FlattenAndValidateAsync(1, CancellationToken.None, validateScripts: false, session)).IsSuccess);
// A template edit lands (the repository bumps the watermark on commit).
_watermark.BumpTemplate(TemplateId);
Assert.True((await _sut.FlattenAndValidateAsync(2, CancellationToken.None, validateScripts: false, session)).IsSuccess);
// The edited template was re-read rather than served from the memo. The
// untouched parent stayed cached — invalidation is per-template, not a
// whole-session reset.
await _templateRepo.Received(2).GetTemplateWithChildrenAsync(TemplateId, Arg.Any<CancellationToken>());
await _templateRepo.Received(1).GetTemplateWithChildrenAsync(ParentTemplateId, Arg.Any<CancellationToken>());
}
[Fact]
public async Task StructuralMutation_InvalidatesCachedChainMembership()
{
ArrangeSharedChain(instanceCount: 2);
var session = _sut.CreateSession();
Assert.True((await _sut.FlattenAndValidateAsync(1, CancellationToken.None, validateScripts: false, session)).IsSuccess);
Assert.Equal(1, session.ChainLoads);
// A re-parent / composition change: chain MEMBERSHIP may now differ, so the
// cached chain must be discarded even though each member's own version is
// unchanged.
_watermark.BumpTemplate(TemplateId, structural: true);
Assert.True((await _sut.FlattenAndValidateAsync(2, CancellationToken.None, validateScripts: false, session)).IsSuccess);
Assert.Equal(2, session.ChainLoads);
}
}
@@ -8,6 +8,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
@@ -40,7 +41,8 @@ public class FlatteningPipelineConnectionBindingTests
new FlatteningService(),
new ValidationService(),
new RevisionHashService(),
_sharedSchemaRepo);
_sharedSchemaRepo,
new TemplateGraphWatermark());
}
/// <summary>
@@ -7,6 +7,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
@@ -40,7 +41,8 @@ public class FlatteningPipelineNativeAlarmCapabilityTests
new FlatteningService(),
new ValidationService(),
new RevisionHashService(),
_sharedSchemaRepo);
_sharedSchemaRepo,
new TemplateGraphWatermark());
}
/// <summary>
@@ -18,7 +18,10 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
@@ -242,13 +245,14 @@ public class ManagementActorTests : TestKit, IDisposable
[Fact]
public void ListTemplatesCommand_ReturnsTemplateData()
{
var templates = new List<Template>
// ListTemplates now reads DB-side row summaries rather than materialising
// every template's full child graph and paging it in memory.
_templateRepo.GetTemplateSummariesAsync(Arg.Any<int>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns(new List<TemplateSummary>
{
new("PumpTemplate") { Id = 1, Description = "Pump" },
new("ValveTemplate") { Id = 2, Description = "Valve" }
};
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(templates);
new(1, "PumpTemplate", "Pump", null, null, false, null, 0, 0, 0, 0, 0),
new(2, "ValveTemplate", "Valve", null, null, false, null, 0, 0, 0, 0, 0)
});
var actor = CreateActor();
var envelope = Envelope(new ListTemplatesCommand());
@@ -343,7 +347,7 @@ public class ManagementActorTests : TestKit, IDisposable
[Fact]
public void ListTemplatesCommand_WhenRepoThrows_ReturnsManagementError()
{
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
_templateRepo.GetTemplateSummariesAsync(Arg.Any<int>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("Database connection lost"));
var actor = CreateActor();
@@ -1138,6 +1142,74 @@ public class ManagementActorTests : TestKit, IDisposable
private static Commons.Entities.Deployment.DeploymentRecord DeploymentRecordFor(int instanceId) =>
new("deploy-" + instanceId, "operator") { Id = instanceId, InstanceId = instanceId };
/// <summary>
/// Row-summary equivalent of <see cref="DeploymentRecordFor"/>. QueryDeployments
/// now returns DB-side projections instead of whole tracked entities.
/// </summary>
private static DeploymentRecordSummary DeploymentSummaryFor(int instanceId) =>
new(instanceId, "deploy-" + instanceId, instanceId, DeploymentStatus.Success,
null, "operator", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null);
/// <summary>
/// Stubs <c>QueryDeploymentSummariesAsync</c> so it applies the same
/// instance / status / scope / paging predicates the database would.
///
/// <para>
/// The filtering moved OUT of the handler and INTO the query, so a stub that
/// ignored the arguments would make these tests pass no matter what the handler
/// passed down. Honouring them here is what keeps the scope-enforcement
/// assertions meaningful.
/// </para>
/// </summary>
private static void StubDeploymentSummaryQuery(
IDeploymentManagerRepository repo,
IEnumerable<int> instanceIds)
{
var rows = instanceIds.Select(DeploymentSummaryFor).ToList();
repo.QueryDeploymentSummariesAsync(
Arg.Any<int?>(), Arg.Any<DeploymentStatus?>(), Arg.Any<IReadOnlyCollection<int>?>(),
Arg.Any<int>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns(ci =>
{
var instanceId = ci.ArgAt<int?>(0);
var scope = ci.ArgAt<IReadOnlyCollection<int>?>(2);
var skip = ci.ArgAt<int>(3);
var take = ci.ArgAt<int?>(4);
IEnumerable<DeploymentRecordSummary> q = rows;
if (instanceId.HasValue)
q = q.Where(r => r.InstanceId == instanceId.Value);
if (scope != null)
q = q.Where(r => scope.Contains(r.InstanceId));
q = q.Skip(Math.Max(0, skip));
if (take is > 0)
q = q.Take(take.Value);
return (IReadOnlyList<DeploymentRecordSummary>)q.ToList();
});
}
/// <summary>
/// Stubs <c>GetTemplateSummariesAsync</c> over <paramref name="count"/> synthetic
/// templates, applying the requested Skip/Take exactly as the database would.
/// </summary>
private void StubTemplateSummaryPaging(int count)
{
var rows = Enumerable.Range(1, count)
.Select(i => new TemplateSummary(i, $"T-{i:D3}", null, null, null, false, null, 0, 0, 0, 0, 0))
.ToList();
_templateRepo.GetTemplateSummariesAsync(Arg.Any<int>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.Returns(ci =>
{
var skip = ci.ArgAt<int>(0);
var take = ci.ArgAt<int?>(1);
IEnumerable<TemplateSummary> q = rows.Skip(Math.Max(0, skip));
if (take is > 0)
q = q.Take(take.Value);
return (IReadOnlyList<TemplateSummary>)q.ToList();
});
}
[Fact]
public void QueryDeployments_WithDesignRole_ReturnsUnauthorized()
{
@@ -1154,11 +1226,7 @@ public class ManagementActorTests : TestKit, IDisposable
public void QueryDeployments_UnfilteredWithDeploymentRole_ReturnsAllRecords()
{
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
deployRepo.GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Deployment.DeploymentRecord>
{
DeploymentRecordFor(1), DeploymentRecordFor(2)
});
StubDeploymentSummaryQuery(deployRepo, [1, 2]);
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
@@ -1175,8 +1243,7 @@ public class ManagementActorTests : TestKit, IDisposable
public void QueryDeployments_FilteredByInstanceId_ReturnsInstanceRecords()
{
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
deployRepo.GetDeploymentsByInstanceIdAsync(5, Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Deployment.DeploymentRecord> { DeploymentRecordFor(5) });
StubDeploymentSummaryQuery(deployRepo, [5]);
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
@@ -1205,7 +1272,8 @@ public class ManagementActorTests : TestKit, IDisposable
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
// The out-of-scope instance's deployment history must not be queried.
deployRepo.DidNotReceiveWithAnyArgs().GetDeploymentsByInstanceIdAsync(default);
deployRepo.DidNotReceiveWithAnyArgs().QueryDeploymentSummariesAsync(
default, default, default, default, default);
}
[Fact]
@@ -1214,8 +1282,7 @@ public class ManagementActorTests : TestKit, IDisposable
_templateRepo.GetInstanceByIdAsync(5, Arg.Any<CancellationToken>())
.Returns(new Instance("Pump5") { Id = 5, SiteId = 1 });
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
deployRepo.GetDeploymentsByInstanceIdAsync(5, Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Deployment.DeploymentRecord> { DeploymentRecordFor(5) });
StubDeploymentSummaryQuery(deployRepo, [5]);
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
@@ -1240,11 +1307,7 @@ public class ManagementActorTests : TestKit, IDisposable
new("Pump2") { Id = 2, SiteId = 2 },
});
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
deployRepo.GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Deployment.DeploymentRecord>
{
DeploymentRecordFor(1), DeploymentRecordFor(2)
});
StubDeploymentSummaryQuery(deployRepo, [1, 2]);
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
@@ -1276,12 +1339,7 @@ public class ManagementActorTests : TestKit, IDisposable
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
});
StubDeploymentSummaryQuery(deployRepo, [1, 2, 3]);
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
@@ -1300,11 +1358,7 @@ public class ManagementActorTests : TestKit, IDisposable
// Admin role bypasses site scoping even with PermittedSiteIds set.
// (The user also holds Deployment so it passes the role gate.)
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
deployRepo.GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Deployment.DeploymentRecord>
{
DeploymentRecordFor(1), DeploymentRecordFor(2)
});
StubDeploymentSummaryQuery(deployRepo, [1, 2]);
_services.AddScoped(_ => deployRepo);
var actor = CreateActor();
@@ -1391,10 +1445,11 @@ public class ManagementActorTests : TestKit, IDisposable
[Fact]
public void ListTemplates_SkipTake_ReturnsRequestedWindow()
{
var templates = Enumerable.Range(1, 30)
.Select(i => new Template($"T-{i:D3}") { Id = i })
.ToList();
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>()).Returns(templates);
// Paging moved into the repository (DB-side Skip/Take). The stub applies
// the same window the database would, so this still pins that the
// command's Skip/Take actually reach the query — the bug being that the
// handler used to load everything and slice in memory.
StubTemplateSummaryPaging(30);
var actor = CreateActor();
actor.Tell(Envelope(new ListTemplatesCommand(Skip: 10, Take: 5)));
@@ -1410,10 +1465,7 @@ public class ManagementActorTests : TestKit, IDisposable
[Fact]
public void ListTemplates_DefaultTake_ReturnsAll()
{
var templates = Enumerable.Range(1, 30)
.Select(i => new Template($"T-{i:D3}") { Id = i })
.ToList();
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>()).Returns(templates);
StubTemplateSummaryPaging(30);
var actor = CreateActor();
actor.Tell(Envelope(new ListTemplatesCommand())); // Take = null => unlimited
@@ -1527,7 +1579,7 @@ public class ManagementActorTests : TestKit, IDisposable
// Repository throws an unanticipated fault carrying sensitive-looking
// detail. The raw text must NOT reach the caller.
const string secret = "Server=db-internal-prod;constraint FK_secret";
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
_templateRepo.GetTemplateSummariesAsync(Arg.Any<int>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidProgramException(secret));
var actor = CreateActor();
@@ -118,6 +118,9 @@ public class RequiredRoleMatrixTests
// ---- Deployer-only ----------------------------------------------------------
["CreateInstance"] = [Roles.Deployer],
["MgmtDeployInstance"] = [Roles.Deployer],
// Bulk site deploy (WP2.5). Same authority as the single-instance deploy
// it batches — it is N of those, not a new class of privilege.
["MgmtDeploySite"] = [Roles.Deployer],
["MgmtEnableInstance"] = [Roles.Deployer],
["MgmtDisableInstance"] = [Roles.Deployer],
["MgmtDeleteInstance"] = [Roles.Deployer],
@@ -57,6 +57,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(parent);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { parent });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { parent });
var result = await _service.CreateTemplateAsync("Child", null, 1, "admin");
@@ -78,7 +80,11 @@ public class TemplateServiceTests
var result = await _service.CreateTemplateAsync("Child", null, 1, "admin");
Assert.True(result.IsSuccess);
// No whole-graph walk of ANY shape — neither the tracked
// GetAllTemplatesAsync nor the no-tracking analysis projection that the
// collision/acyclicity checks now use.
_repoMock.Verify(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()), Times.Never);
_repoMock.Verify(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
@@ -112,6 +118,8 @@ public class TemplateServiceTests
.ReturnsAsync(new List<Instance>());
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
var result = await _service.DeleteTemplateAsync(1, "admin");
@@ -128,6 +136,8 @@ public class TemplateServiceTests
.ReturnsAsync(new List<Instance> { new Instance("Pump1") { Id = 1, TemplateId = 1, SiteId = 1 } });
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
var result = await _service.DeleteTemplateAsync(1, "admin");
@@ -145,6 +155,8 @@ public class TemplateServiceTests
.ReturnsAsync(new List<Instance>());
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { parent, child });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { parent, child });
var result = await _service.DeleteTemplateAsync(1, "admin");
@@ -164,6 +176,8 @@ public class TemplateServiceTests
.ReturnsAsync(new List<Instance>());
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { moduleTemplate, composingTemplate });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { moduleTemplate, composingTemplate });
var result = await _service.DeleteTemplateAsync(1, "admin");
@@ -191,6 +205,13 @@ public class TemplateServiceTests
new Template("Child") { Id = 2, ParentTemplateId = 1 },
composer
});
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template>
{
template,
new Template("Child") { Id = 2, ParentTemplateId = 1 },
composer
});
var result = await _service.DeleteTemplateAsync(1, "admin");
@@ -211,6 +232,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
var attr = new TemplateAttribute("Temperature") { DataType = DataType.Float, Value = "0.0" };
var result = await _service.AddAttributeAsync(1, attr, "admin");
@@ -240,6 +263,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(8, It.IsAny<CancellationToken>())).ReturnsAsync(child);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { parent, child });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { parent, child });
var attr = new TemplateAttribute("MoveInType") { DataType = DataType.String, Value = "" };
var result = await _service.AddAttributeAsync(8, attr, "admin");
@@ -360,6 +385,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
var attr = new TemplateAttribute("Temperature") { DataType = DataType.Int32, Value = "not-a-number" };
var result = await _service.AddAttributeAsync(1, attr, "admin");
@@ -376,6 +403,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
var attr = new TemplateAttribute("SetPoints")
{
@@ -447,6 +476,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
var alarm = new TemplateAlarm("HighTemp")
{
@@ -510,6 +541,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
var script = new TemplateScript("OnStart", "return true;") { TriggerType = "Startup" };
var result = await _service.AddScriptAsync(1, script, "admin");
@@ -610,6 +643,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(moduleTemplate);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template, moduleTemplate });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template, moduleTemplate });
Template? captured = null;
_repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
@@ -651,6 +686,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(11, It.IsAny<CancellationToken>())).ReturnsAsync(sensorProbe1);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { pump, sensor, probe, sensorProbe1 });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { pump, sensor, probe, sensorProbe1 });
var captured = new List<Template>();
_repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
@@ -705,6 +742,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(moduleTemplate);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template, moduleTemplate, existing });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template, moduleTemplate, existing });
var captured = new List<Template>();
_repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
@@ -871,6 +910,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseTemplate, derived });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseTemplate, derived });
var proposed = new TemplateAttribute("SetPoint") { Value = "99", DataType = DataType.Float, IsInherited = false };
var result = await _service.UpdateAttributeAsync(100, proposed, "admin");
@@ -892,6 +933,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseTemplate, derived });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseTemplate, derived });
var proposed = new TemplateScript("Sample", "return 2;") { IsInherited = false };
var result = await _service.UpdateScriptAsync(200, proposed, "admin");
@@ -919,6 +962,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(moduleTemplate);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template, moduleTemplate });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template, moduleTemplate });
Template? captured = null;
_repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
@@ -965,6 +1010,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseTemplate, derived });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseTemplate, derived });
var proposed = new TemplateAlarm("HighTemp")
{
@@ -1003,6 +1050,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseTemplate, derived });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseTemplate, derived });
var proposed = new TemplateAlarm("HighTemp")
{
@@ -1030,6 +1079,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseTemplate, derived });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseTemplate, derived });
var proposed = new TemplateAttribute("SetPoint") { Value = "99", DataType = DataType.Float, IsInherited = false };
var result = await _service.UpdateAttributeAsync(100, proposed, "admin");
@@ -1052,6 +1103,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(5, It.IsAny<CancellationToken>())).ReturnsAsync(new List<Instance>());
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseTemplate, parent, derived });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseTemplate, parent, derived });
var result = await _service.DeleteTemplateAsync(5, "admin");
@@ -1083,6 +1136,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
var result = await _service.AddCompositionAsync(1, 1, "self", "admin");
@@ -1206,6 +1261,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(childTemplate);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { parentTemplate, childTemplate });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { parentTemplate, childTemplate });
var proposed = new TemplateAttribute("Speed")
{
@@ -1241,6 +1298,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(childTemplate);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { parentTemplate, childTemplate });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { parentTemplate, childTemplate });
var result = await _service.DeleteAttributeAsync(20, "admin");
@@ -1295,6 +1354,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(child);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { child });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { child });
var result = await _service.UpdateTemplateAsync(2, "ChildRenamed", null, null, "admin");
@@ -1313,6 +1374,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(t);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { t });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { t });
var result = await _service.UpdateTemplateAsync(1, "T", "", null, "admin");
@@ -1328,6 +1391,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(child);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { child });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { child });
// Idempotent pass — same parent value sent on update should succeed and apply name/description changes.
var result = await _service.UpdateTemplateAsync(2, "ChildRenamed", "new", 1, "admin");
@@ -1353,6 +1418,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(templateA);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { templateA, templateB, templateC });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { templateA, templateB, templateC });
var result = await _service.AddCompositionAsync(3, 1, "a1", "admin");
@@ -1373,6 +1440,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetFolderByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync(folder);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { t });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { t });
var result = await _service.MoveTemplateAsync(1, 7, "admin");
@@ -1387,6 +1456,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(t);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { t });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { t });
var result = await _service.MoveTemplateAsync(1, null, "admin");
@@ -1422,6 +1493,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetFolderByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync(folder);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { moving, existing });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { moving, existing });
var result = await _service.MoveTemplateAsync(1, 7, "admin");
@@ -1444,6 +1517,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetFolderByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync(folder);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { moving, unrelated });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { moving, unrelated });
var result = await _service.MoveTemplateAsync(1, 7, "admin");
@@ -1536,6 +1611,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
TemplateAttribute? added = null;
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
@@ -1576,6 +1653,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
TemplateAlarm? added = null;
_repoMock.Setup(r => r.AddTemplateAlarmAsync(It.IsAny<TemplateAlarm>(), It.IsAny<CancellationToken>()))
@@ -1621,6 +1700,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { template });
TemplateScript? added = null;
_repoMock.Setup(r => r.AddTemplateScriptAsync(It.IsAny<TemplateScript>(), It.IsAny<CancellationToken>()))
@@ -1676,6 +1757,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
var added = new List<TemplateAttribute>();
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
.Callback<TemplateAttribute, CancellationToken>((a, _) => added.Add(a))
@@ -1707,6 +1790,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
var deleted = new List<int>();
_repoMock.Setup(r => r.DeleteTemplateAttributeAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.Callback<int, CancellationToken>((id, _) => deleted.Add(id))
@@ -1731,6 +1816,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
var updated = new List<TemplateAttribute>();
_repoMock.Setup(r => r.UpdateTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
.Callback<TemplateAttribute, CancellationToken>((a, _) => updated.Add(a))
@@ -1756,6 +1843,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
var result = await _service.ResyncInheritedMembersAsync(8, "admin");
@@ -1781,6 +1870,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, left, right });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, left, right });
var added = new List<TemplateAttribute>();
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
.Callback<TemplateAttribute, CancellationToken>((a, _) => added.Add(a))
@@ -1809,6 +1900,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
var addedScripts = new List<TemplateScript>();
var addedSources = new List<TemplateNativeAlarmSource>();
_repoMock.Setup(r => r.AddTemplateScriptAsync(It.IsAny<TemplateScript>(), It.IsAny<CancellationToken>()))
@@ -1846,6 +1939,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
var added = new List<TemplateAttribute>();
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
.Callback<TemplateAttribute, CancellationToken>((a, _) => added.Add(a)).Returns(Task.CompletedTask);
@@ -1871,6 +1966,8 @@ public class TemplateServiceTests
_repoMock.Setup(r => r.GetTemplateByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync(baseT);
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Template> { baseT, child });
var added = new List<TemplateAttribute>();
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
@@ -0,0 +1,135 @@
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Validation;
/// <summary>
/// WP2.5: the verdict cache's eviction policy. Overflow used to <c>Clear()</c> the
/// whole cache, which re-opened the non-collectible <c>InteractiveAssemblyLoader</c>
/// leak the cache exists to bound — every hot script had to be recompiled, and every
/// recompile loads another assembly that can never be unloaded. These tests pin the
/// replacement: eviction is segmented, so entries in active use survive it.
///
/// <para>
/// Serialised with the other verdict-cache tests: the cache is process-wide static
/// state, so two test classes filling it concurrently would see each other's
/// entries.
/// </para>
/// </summary>
[Collection("ScriptCompileVerdictCache")]
public class ScriptCompileVerdictCacheEvictionTests
{
private const string Surface = "TestSurface";
/// <summary>Entries needed to force at least one generation rotation.</summary>
private const int OverflowCount = 5000;
private static (bool Ok, string? Error) Lookup(string code, Func<(bool, string?)> factory) =>
ScriptCompileVerdictCache.GetOrAdd(Surface, code, factory);
[Fact]
public void Overflow_KeepsHotEntry_AndDoesNotClearEverything()
{
ScriptCompileVerdictCache.Clear();
const string hotCode = "// the script every deploy re-validates";
var hotCompiles = 0;
(bool, string?) HotFactory()
{
hotCompiles++;
return (true, null);
}
Lookup(hotCode, HotFactory);
Assert.Equal(1, hotCompiles);
// Push far more distinct scripts through than the cache can hold, touching
// the hot entry as we go — which is exactly what a real workload does, and
// exactly what wholesale Clear() used to throw away.
for (var i = 0; i < OverflowCount; i++)
{
Lookup($"// filler {i}", static () => (true, null));
if (i % 25 == 0)
Lookup(hotCode, HotFactory);
}
// At least one rotation happened...
Assert.True(ScriptCompileVerdictCache.Evictions > 0,
"the overflow did not trigger a single eviction — the test no longer exercises the policy");
// ...and the hot entry was never recompiled, because a hit in the cold
// generation promotes it back into hot rather than letting it age out.
Assert.Equal(1, hotCompiles);
// A final read still hits.
var compilesBefore = hotCompiles;
Lookup(hotCode, HotFactory);
Assert.Equal(compilesBefore, hotCompiles);
}
[Fact]
public void Overflow_RetainsRecentEntries_RatherThanDroppingAll()
{
ScriptCompileVerdictCache.Clear();
for (var i = 0; i < OverflowCount; i++)
Lookup($"// bulk {i}", static () => (true, null));
Assert.True(ScriptCompileVerdictCache.Evictions > 0);
// The most recently inserted entry is in the hot generation, so it must
// still be cached. Under the old Clear()-on-overflow policy the cache could
// be left holding a single entry after a rotation.
var recompiled = false;
Lookup($"// bulk {OverflowCount - 1}", () =>
{
recompiled = true;
return (true, null);
});
Assert.False(recompiled, "the most recent entry was evicted; eviction is not retaining the hot generation");
Assert.True(ScriptCompileVerdictCache.Count > 1,
$"cache retained only {ScriptCompileVerdictCache.Count} entries after eviction");
}
[Fact]
public void Overflow_KeepsCacheBounded()
{
ScriptCompileVerdictCache.Clear();
for (var i = 0; i < OverflowCount; i++)
Lookup($"// bounded {i}", static () => (true, null));
// Two generations of 2048 — the same 4096 ceiling the previous policy had,
// now reached by demotion rather than by dropping everything.
Assert.True(ScriptCompileVerdictCache.Count <= 4096,
$"cache grew to {ScriptCompileVerdictCache.Count} entries, exceeding its two-generation bound");
}
[Fact]
public void SurfaceIsPartOfTheKey_AcrossEviction()
{
ScriptCompileVerdictCache.Clear();
const string code = "// same body, two surfaces";
Lookup(code, static () => (true, null));
var otherSurfaceCompiled = false;
var verdict = ScriptCompileVerdictCache.GetOrAdd("OtherSurface", code, () =>
{
otherSurfaceCompiled = true;
return (false, "not valid against this surface");
});
// A verdict is never interchangeable across globals surfaces, and the
// segmented cache must not weaken that.
Assert.True(otherSurfaceCompiled);
Assert.False(verdict.Ok);
}
}
/// <summary>
/// Serialises every test that touches the process-wide
/// <see cref="ScriptCompileVerdictCache"/> static.
/// </summary>
[CollectionDefinition("ScriptCompileVerdictCache")]
public class ScriptCompileVerdictCacheCollection;
@@ -10,6 +10,10 @@ namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Validation;
/// authoritative behavior: bypasses the old substring scan missed are now caught,
/// and undefined symbols (which a structural scan could never see) fail compile.
/// </summary>
// Serialised with ScriptCompileVerdictCacheEvictionTests: both drive the
// process-wide ScriptCompileVerdictCache static, and an overflow test running
// concurrently would evict the entries these hit-count assertions depend on.
[Collection("ScriptCompileVerdictCache")]
public class ScriptCompilerTests
{
private readonly ScriptCompiler _sut = new();
@@ -12,6 +12,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests;
@@ -37,6 +38,7 @@ public sealed class CompositionImportTests : IDisposable
.UseInMemoryDatabase(dbName)
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
@@ -11,6 +11,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests;
@@ -35,6 +36,7 @@ public sealed class ConflictResolutionTests : IDisposable
.UseInMemoryDatabase(dbName)
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
@@ -19,6 +19,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Encryption;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Export;
@@ -67,6 +68,7 @@ public sealed class BundleExporterTests : IDisposable
// ISiteRepository to walk the site/data-connection/instance closure, so it
// must be registered or the BuildServiceProvider-time graph resolution for
// DependencyResolver fails.
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
@@ -16,6 +16,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Import;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
@@ -46,6 +47,7 @@ public sealed class AreaTransportTests : IDisposable
.UseInMemoryDatabase(dbName)
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
@@ -24,6 +24,7 @@ using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Import;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
@@ -76,6 +77,7 @@ public sealed class BundleImporterApplyTests : IDisposable
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
sp.GetRequiredService<IDataProtectionProvider>()));
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
@@ -17,6 +17,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
@@ -40,6 +41,7 @@ public sealed class BundleImporterPreviewTests : IDisposable
var dbName = $"BundleImporterPreviewTests_{Guid.NewGuid()}";
services.AddDbContext<ScadaBridgeDbContext>(opts => opts.UseInMemoryDatabase(dbName));
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
@@ -18,6 +18,7 @@ using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Import;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
@@ -80,6 +81,7 @@ public sealed class BundleImporterRetryingStrategyTests : IDisposable
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
sp.GetRequiredService<IDataProtectionProvider>()));
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
@@ -19,6 +19,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Import;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
@@ -81,6 +82,7 @@ public sealed class BundleImporterRollbackFailureTests : IDisposable
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
sp.GetRequiredService<IDataProtectionProvider>()));
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
@@ -12,6 +12,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
@@ -52,6 +53,7 @@ public sealed class CreateMissingSiteRelationalTests : IDisposable
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
sp.GetRequiredService<IDataProtectionProvider>()));
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
@@ -13,6 +13,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Import;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
@@ -38,6 +39,7 @@ public sealed class InheritanceImportTests : IDisposable
.UseInMemoryDatabase(dbName)
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
@@ -17,6 +17,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
@@ -49,6 +50,7 @@ public sealed class NativeAlarmSourceImportTests : IDisposable
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
sp.GetRequiredService<IDataProtectionProvider>()));
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
@@ -16,6 +16,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Import;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
@@ -53,6 +54,7 @@ public sealed class SiteInstanceImportTests : IDisposable
.UseInMemoryDatabase(dbName)
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
@@ -16,6 +16,7 @@ using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
@@ -48,6 +49,7 @@ public sealed class TemplateScriptFidelityTests : IDisposable
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
sp.GetRequiredService<IDataProtectionProvider>()));
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
@@ -21,6 +21,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests;
@@ -91,6 +92,7 @@ public sealed class RoundTripEquivalenceTests : IDisposable
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
sp.GetRequiredService<IDataProtectionProvider>()));
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
@@ -16,6 +16,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests;
@@ -60,6 +61,7 @@ public sealed class RoundTripTests : IDisposable
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
sp.GetRequiredService<IDataProtectionProvider>()));
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
@@ -15,6 +15,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Import;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests;
@@ -49,6 +50,7 @@ public sealed class SemanticValidatorImportTests : IDisposable
.UseInMemoryDatabase(dbName)
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
@@ -14,6 +14,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Import;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests;
@@ -38,6 +39,7 @@ public sealed class ValidationFailureTests : IDisposable
.UseInMemoryDatabase(dbName)
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();