Merge branch 'worktree-agent-a0098b40576d74cfd' into arch-review-remediation
This commit is contained in:
@@ -137,6 +137,57 @@ When deploying artifacts (shared scripts, external system definitions, etc.) to
|
|||||||
- Deployment is performed at the **individual instance level**.
|
- 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.
|
- 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
|
## Diff View
|
||||||
|
|
||||||
Before deploying, the Deployment Manager can request a diff from the Template Engine showing:
|
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
|
### 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.
|
- **CreateTemplate** / **UpdateTemplate** / **DeleteTemplate**: Manage templates.
|
||||||
- **ValidateTemplate**: Run on-demand pre-deployment validation (flattening, naming collisions, script compilation).
|
- **ValidateTemplate**: Run on-demand pre-deployment validation (flattening, naming collisions, script compilation).
|
||||||
- **GetTemplateDiff**: Compare deployed vs. template-derived configuration for an instance.
|
- **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
|
### 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.
|
- **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.
|
- **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.
|
- **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).
|
- **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).
|
- **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.
|
- **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.
|
- **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)
|
### Secured Writes (MxGateway, two-person)
|
||||||
|
|
||||||
@@ -259,7 +262,7 @@ The ManagementActor receives the following services and repositories via DI (inj
|
|||||||
| Section | Options Class | Contents |
|
| 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. |
|
| `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). |
|
| | | `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
|
## 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.
|
`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
|
#### 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.
|
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.
|
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.
|
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
|
### 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`.
|
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" };
|
var command = new Command("deploy") { Description = "Deployment operations" };
|
||||||
|
|
||||||
command.Add(BuildInstance(urlOption, formatOption, usernameOption, passwordOption));
|
command.Add(BuildInstance(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
|
command.Add(BuildSite(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildArtifacts(urlOption, formatOption, usernameOption, passwordOption));
|
command.Add(BuildArtifacts(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
command.Add(BuildStatus(urlOption, formatOption, usernameOption, passwordOption));
|
command.Add(BuildStatus(urlOption, formatOption, usernameOption, passwordOption));
|
||||||
|
|
||||||
@@ -39,6 +40,43 @@ public static class DeployCommands
|
|||||||
return cmd;
|
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)
|
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)" };
|
var siteIdOption = new Option<int?>("--site-id") { Description = "Target site ID (all sites if omitted)" };
|
||||||
|
|||||||
@@ -952,6 +952,37 @@ scadabridge --url <url> deploy instance --id <int>
|
|||||||
|--------|----------|-------------|
|
|--------|----------|-------------|
|
||||||
| `--id` | yes | Instance ID |
|
| `--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 artifacts`
|
||||||
|
|
||||||
Deploy compiled artifacts to one or all sites (same as `site deploy-artifacts`).
|
Deploy compiled artifacts to one or all sites (same as `site deploy-artifacts`).
|
||||||
|
|||||||
+42
@@ -22,6 +22,48 @@ public interface IDeploymentManagerRepository
|
|||||||
/// <returns>A read-only list of all deployment records.</returns>
|
/// <returns>A read-only list of all deployment records.</returns>
|
||||||
Task<IReadOnlyList<DeploymentRecord>> GetAllDeploymentRecordsAsync(CancellationToken cancellationToken = default);
|
Task<IReadOnlyList<DeploymentRecord>> GetAllDeploymentRecordsAsync(CancellationToken cancellationToken = default);
|
||||||
/// <summary>
|
/// <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.
|
/// Gets all deployment records for a specific instance.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="instanceId">The instance ID.</param>
|
/// <param name="instanceId">The instance ID.</param>
|
||||||
|
|||||||
+35
@@ -35,6 +35,41 @@ public interface ITemplateEngineRepository
|
|||||||
/// <returns>A task that resolves to a read-only list of all templates.</returns>
|
/// <returns>A task that resolves to a read-only list of all templates.</returns>
|
||||||
Task<IReadOnlyList<Template>> GetAllTemplatesAsync(CancellationToken cancellationToken = default);
|
Task<IReadOnlyList<Template>> GetAllTemplatesAsync(CancellationToken cancellationToken = default);
|
||||||
/// <summary>
|
/// <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
|
/// Returns every template that contains a composition referencing
|
||||||
/// <paramref name="composedTemplateId"/>. Each result is eager-loaded with
|
/// <paramref name="composedTemplateId"/>. Each result is eager-loaded with
|
||||||
/// its Attributes / Scripts / Compositions so the caller can build a
|
/// 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;
|
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||||
|
|
||||||
public record MgmtDeployArtifactsCommand(int? SiteId = null);
|
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 QueryDeploymentsCommand(int? InstanceId = null, string? Status = null, int Page = 1, int PageSize = 50);
|
||||||
public record GetDeploymentDiffCommand(int InstanceId);
|
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;
|
||||||
|
}
|
||||||
+83
@@ -277,6 +277,89 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
|
|||||||
return expired.Count;
|
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 ---
|
// --- Startup reconciliation ---
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
+224
-2
@@ -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.Scripts;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
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;
|
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||||
|
|
||||||
public class TemplateEngineRepository : ITemplateEngineRepository
|
public class TemplateEngineRepository : ITemplateEngineRepository
|
||||||
{
|
{
|
||||||
private readonly ScadaBridgeDbContext _context;
|
private readonly ScadaBridgeDbContext _context;
|
||||||
|
private readonly ITemplateGraphWatermark _watermark;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the TemplateEngineRepository class.
|
/// Initializes a new instance of the TemplateEngineRepository class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="context">The database context used to access template and instance data.</param>
|
/// <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));
|
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||||
|
_watermark = watermark ?? throw new ArgumentNullException(nameof(watermark));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Template
|
// Template
|
||||||
@@ -78,6 +90,95 @@ public class TemplateEngineRepository : ITemplateEngineRepository
|
|||||||
.ToListAsync(cancellationToken);
|
.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 />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<Template>> GetTemplatesComposingAsync(int composedTemplateId, CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<Template>> GetTemplatesComposingAsync(int composedTemplateId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -659,8 +760,129 @@ public class TemplateEngineRepository : ITemplateEngineRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <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)
|
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.AspNetCore.DataProtection;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
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.Maintenance;
|
||||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
||||||
@@ -65,6 +67,13 @@ public static class ServiceCollectionExtensions
|
|||||||
return new ScadaBridgeDbContext(options, protectionProvider);
|
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<ISecurityRepository, SecurityRepository>();
|
||||||
services.AddScoped<ICentralUiRepository, CentralUiRepository>();
|
services.AddScoped<ICentralUiRepository, CentralUiRepository>();
|
||||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||||
|
|||||||
@@ -17,4 +17,43 @@ public class DeploymentManagerOptions
|
|||||||
|
|
||||||
/// <summary>Timeout for acquiring an operation lock on an instance.</summary>
|
/// <summary>Timeout for acquiring an operation lock on an instance.</summary>
|
||||||
public TimeSpan OperationLockTimeout { get; set; } = TimeSpan.FromSeconds(5);
|
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,
|
builder.RequireThat(options.OperationLockTimeout > TimeSpan.Zero,
|
||||||
$"ScadaBridge:DeploymentManager:OperationLockTimeout must be a positive duration " +
|
$"ScadaBridge:DeploymentManager:OperationLockTimeout must be a positive duration " +
|
||||||
$"(was {options.OperationLockTimeout}); it bounds acquiring an instance operation lock.");
|
$"(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>
|
/// </summary>
|
||||||
private const string TimeoutFailurePrefix = "Communication failure:";
|
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>
|
/// <summary>
|
||||||
/// Initializes a new instance of <see cref="DeploymentService"/> with all required dependencies.
|
/// Initializes a new instance of <see cref="DeploymentService"/> with all required dependencies.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -161,28 +179,113 @@ public class DeploymentService
|
|||||||
int instanceId,
|
int instanceId,
|
||||||
string user,
|
string user,
|
||||||
CancellationToken cancellationToken = default)
|
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
|
// Load instance
|
||||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||||
if (instance == null)
|
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
|
// Validate state transition
|
||||||
var transitionError = StateTransitionValidator.ValidateTransition(instance.State, "deploy");
|
var transitionError = StateTransitionValidator.ValidateTransition(instance.State, "deploy");
|
||||||
if (transitionError != null)
|
if (transitionError != null)
|
||||||
return Result<DeploymentRecord>.Failure(transitionError);
|
return PreparedDeployment.Resolved(Result<DeploymentRecord>.Failure(transitionError));
|
||||||
|
|
||||||
// Acquire per-instance operation lock
|
// Acquire per-instance operation lock
|
||||||
using var lockHandle = await _lockManager.AcquireAsync(
|
var lockHandle = await _lockManager.AcquireAsync(
|
||||||
instance.UniqueName, _options.OperationLockTimeout, cancellationToken);
|
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
|
// Generate unique deployment ID
|
||||||
var deploymentId = Guid.NewGuid().ToString("N");
|
var deploymentId = Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
// Flatten configuration (captures template state at this point in time)
|
// 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)
|
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 flattenedConfig = flattenResult.Value.Configuration;
|
||||||
var revisionHash = flattenResult.Value.RevisionHash;
|
var revisionHash = flattenResult.Value.RevisionHash;
|
||||||
@@ -200,8 +303,9 @@ public class DeploymentService
|
|||||||
validationResult.Errors.Count,
|
validationResult.Errors.Count,
|
||||||
string.Join("; ", validationResult.Errors.Select(e => e.Message)));
|
string.Join("; ", validationResult.Errors.Select(e => e.Message)));
|
||||||
|
|
||||||
return Result<DeploymentRecord>.Failure(
|
lockHandle.Dispose();
|
||||||
$"Pre-deployment validation failed: {validationResult.SummarizeErrors()}");
|
return PreparedDeployment.Resolved(Result<DeploymentRecord>.Failure(
|
||||||
|
$"Pre-deployment validation failed: {validationResult.SummarizeErrors()}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serialize for transmission (also the payload stored in the deployed
|
// Serialize for transmission (also the payload stored in the deployed
|
||||||
@@ -218,7 +322,10 @@ public class DeploymentService
|
|||||||
var reconciled = await TryReconcileWithSiteAsync(
|
var reconciled = await TryReconcileWithSiteAsync(
|
||||||
instance, revisionHash, configJson, user, cancellationToken);
|
instance, revisionHash, configJson, user, cancellationToken);
|
||||||
if (reconciled != null)
|
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
|
// Notify-and-fetch: the site fetches the staged config from
|
||||||
// CentralFetchBaseUrl, so a deploy is impossible without it. Fail fast
|
// 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
|
// confusing downstream site-fetch failure (and no InProgress record is
|
||||||
// stranded).
|
// stranded).
|
||||||
if (string.IsNullOrEmpty(_commOptions.CentralFetchBaseUrl))
|
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.
|
// Create the deployment record directly in InProgress.
|
||||||
//
|
//
|
||||||
@@ -275,12 +385,8 @@ public class DeploymentService
|
|||||||
deploymentId, instance.UniqueName, revisionHash, user, stagedAt,
|
deploymentId, instance.UniqueName, revisionHash, user, stagedAt,
|
||||||
_commOptions.CentralFetchBaseUrl, token);
|
_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
|
// 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
|
// central-side Ask timeout the site may have applied AND told the
|
||||||
// standby node to fetch; deleting now would 404 that in-flight
|
// standby node to fetch; deleting now would 404 that in-flight
|
||||||
// standby fetch and break failover. Supersession bounds pending rows
|
// 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
|
// leaving rows for TTL purge is safe. Expired rows are swept by the
|
||||||
// central PendingDeploymentPurgeActor singleton on its
|
// central PendingDeploymentPurgeActor singleton on its
|
||||||
// CommunicationOptions.PendingDeploymentPurgeInterval cadence.
|
// 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.
|
// Update status based on site response.
|
||||||
record.Status = response.Status;
|
record.Status = response.Status;
|
||||||
record.ErrorMessage = response.ErrorMessage;
|
record.ErrorMessage = response.ErrorMessage;
|
||||||
@@ -322,7 +509,7 @@ public class DeploymentService
|
|||||||
// logged loudly for operator reconciliation but must not flip
|
// logged loudly for operator reconciliation but must not flip
|
||||||
// the already-committed Success record back to Failed.
|
// the already-committed Success record back to Failed.
|
||||||
await ApplyPostSuccessSideEffectsAsync(
|
await ApplyPostSuccessSideEffectsAsync(
|
||||||
instance, deploymentId, revisionHash, configJson,
|
instance, deploymentId, prepared.RevisionHash, prepared.ConfigJson,
|
||||||
forceEnabledState: true, cancellationToken);
|
forceEnabledState: true, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,61 +532,366 @@ public class DeploymentService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Any exception out of the try (timeout,
|
// A fault in the post-send persistence path is handled identically to
|
||||||
// cancellation, transport, serialization, DB) must leave the
|
// a send fault: the record must never be left InProgress.
|
||||||
// deployment record as Failed -- the design requires an interrupted
|
await MarkDeploymentFailedAsync(record, instance, deploymentId, user, ex);
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogError(ex,
|
_logger.LogError(ex,
|
||||||
"Deployment {DeploymentId} for instance {Instance} failed",
|
"Deployment {DeploymentId} for instance {Instance} failed",
|
||||||
deploymentId, instance.UniqueName);
|
deploymentId, instance.UniqueName);
|
||||||
|
|
||||||
return Result<DeploymentRecord>.Failure(
|
return FailureResultFor(ex);
|
||||||
isTimeout
|
|
||||||
? $"Deployment timed out: {ex.Message}"
|
|
||||||
: $"Deployment failed: {ex.Message}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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>
|
/// <summary>
|
||||||
/// Disable an instance. Stops Instance Actor, retains config, S&F drains.
|
/// Disable an instance. Stops Instance Actor, retains config, S&F drains.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -1178,3 +1670,37 @@ public record DeploymentComparisonResult(
|
|||||||
bool IsStale,
|
bool IsStale,
|
||||||
DateTimeOffset DeployedAt,
|
DateTimeOffset DeployedAt,
|
||||||
ConfigurationDiff? Diff = null);
|
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.Entities.Sites;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
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;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
|
||||||
@@ -23,6 +24,7 @@ public class FlatteningPipeline : IFlatteningPipeline
|
|||||||
private readonly ValidationService _validationService;
|
private readonly ValidationService _validationService;
|
||||||
private readonly RevisionHashService _revisionHashService;
|
private readonly RevisionHashService _revisionHashService;
|
||||||
private readonly ISharedSchemaRepository _sharedSchemaRepo;
|
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>
|
/// <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>
|
/// <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
|
/// look up <c>lib:Name</c> library references so a dangling reference in any validated
|
||||||
/// script schema becomes a deploy-blocking error.
|
/// script schema becomes a deploy-blocking error.
|
||||||
/// </param>
|
/// </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(
|
public FlatteningPipeline(
|
||||||
ITemplateEngineRepository templateRepo,
|
ITemplateEngineRepository templateRepo,
|
||||||
ISiteRepository siteRepo,
|
ISiteRepository siteRepo,
|
||||||
FlatteningService flatteningService,
|
FlatteningService flatteningService,
|
||||||
ValidationService validationService,
|
ValidationService validationService,
|
||||||
RevisionHashService revisionHashService,
|
RevisionHashService revisionHashService,
|
||||||
ISharedSchemaRepository sharedSchemaRepo)
|
ISharedSchemaRepository sharedSchemaRepo,
|
||||||
|
ITemplateGraphWatermark watermark)
|
||||||
{
|
{
|
||||||
_templateRepo = templateRepo;
|
_templateRepo = templateRepo;
|
||||||
_siteRepo = siteRepo;
|
_siteRepo = siteRepo;
|
||||||
@@ -49,21 +57,35 @@ public class FlatteningPipeline : IFlatteningPipeline
|
|||||||
_validationService = validationService;
|
_validationService = validationService;
|
||||||
_revisionHashService = revisionHashService;
|
_revisionHashService = revisionHashService;
|
||||||
_sharedSchemaRepo = sharedSchemaRepo;
|
_sharedSchemaRepo = sharedSchemaRepo;
|
||||||
|
_watermark = watermark;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public FlattenSession CreateSession() => new(_watermark);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<Result<FlatteningPipelineResult>> FlattenAndValidateAsync(
|
public async Task<Result<FlatteningPipelineResult>> FlattenAndValidateAsync(
|
||||||
int instanceId,
|
int instanceId,
|
||||||
CancellationToken cancellationToken = default,
|
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
|
// Load instance with full graph
|
||||||
var instance = await _templateRepo.GetInstanceByIdAsync(instanceId, cancellationToken);
|
var instance = await _templateRepo.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||||
if (instance == null)
|
if (instance == null)
|
||||||
return Result<FlatteningPipelineResult>.Failure($"Instance with ID {instanceId} not found.");
|
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
|
// Build template chain
|
||||||
var templateChain = await BuildTemplateChainAsync(instance.TemplateId, cancellationToken);
|
var templateChain = await session.GetChainAsync(instance.TemplateId, LoadTemplate);
|
||||||
if (templateChain.Count == 0)
|
if (templateChain.Count == 0)
|
||||||
return Result<FlatteningPipelineResult>.Failure("Template chain is empty.");
|
return Result<FlatteningPipelineResult>.Failure("Template chain is empty.");
|
||||||
|
|
||||||
@@ -84,7 +106,9 @@ public class FlatteningPipeline : IFlatteningPipeline
|
|||||||
{
|
{
|
||||||
if (!processedTemplateIds.Add(template.Id)) continue;
|
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;
|
if (compositions.Count == 0) continue;
|
||||||
|
|
||||||
compositionMap[template.Id] = compositions;
|
compositionMap[template.Id] = compositions;
|
||||||
@@ -92,7 +116,7 @@ public class FlatteningPipeline : IFlatteningPipeline
|
|||||||
{
|
{
|
||||||
if (composedChains.ContainsKey(comp.ComposedTemplateId)) continue;
|
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;
|
composedChains[comp.ComposedTemplateId] = composedChain;
|
||||||
pendingChains.Enqueue(composedChain);
|
pendingChains.Enqueue(composedChain);
|
||||||
}
|
}
|
||||||
@@ -100,7 +124,9 @@ public class FlatteningPipeline : IFlatteningPipeline
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load data connections for the site
|
// 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
|
// Flatten
|
||||||
var flattenResult = _flatteningService.Flatten(
|
var flattenResult = _flatteningService.Flatten(
|
||||||
@@ -111,15 +137,20 @@ public class FlatteningPipeline : IFlatteningPipeline
|
|||||||
|
|
||||||
var config = flattenResult.Value;
|
var config = flattenResult.Value;
|
||||||
|
|
||||||
// Load shared scripts for semantic validation
|
// Load shared scripts for semantic validation. Session-global: identical
|
||||||
var sharedScriptEntities = await _templateRepo.GetAllSharedScriptsAsync(cancellationToken);
|
// for every instance in a batch, so hoisted to one query per session
|
||||||
var resolvedSharedScripts = sharedScriptEntities.Select(s => new ResolvedScript
|
// (the ArtifactDeploymentService global-hoist shape).
|
||||||
|
var resolvedSharedScripts = await session.GetSharedScriptsAsync(async () =>
|
||||||
{
|
{
|
||||||
CanonicalName = s.Name,
|
var sharedScriptEntities = await _templateRepo.GetAllSharedScriptsAsync(cancellationToken);
|
||||||
Code = s.Code,
|
return (IReadOnlyList<ResolvedScript>)sharedScriptEntities.Select(s => new ResolvedScript
|
||||||
ParameterDefinitions = s.ParameterDefinitions,
|
{
|
||||||
ReturnDefinition = s.ReturnDefinition
|
CanonicalName = s.Name,
|
||||||
}).ToList();
|
Code = s.Code,
|
||||||
|
ParameterDefinitions = s.ParameterDefinitions,
|
||||||
|
ReturnDefinition = s.ReturnDefinition
|
||||||
|
}).ToList();
|
||||||
|
});
|
||||||
|
|
||||||
// Compute the alarm-capable connection-name set so the semantic validator
|
// Compute the alarm-capable connection-name set so the semantic validator
|
||||||
// can gate native-alarm-source bindings. "Alarm-capable" matches the DCL
|
// 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
|
// 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
|
// seam is a pure in-memory lookup. An unresolved {"$ref":"lib:Name"} in any
|
||||||
// validated script schema then becomes a deploy-blocking SchemaReference error.
|
// validated script schema then becomes a deploy-blocking SchemaReference error.
|
||||||
var sharedSchemas = await _sharedSchemaRepo.ListAsync(cancellationToken);
|
// Session-global, same as the shared scripts above.
|
||||||
var schemaLibrary = sharedSchemas.ToDictionary(s => s.Name, s => s.SchemaJson, StringComparer.Ordinal);
|
var schemaLibrary = await session.GetSchemaLibraryAsync(async () =>
|
||||||
|
{
|
||||||
|
var sharedSchemas = await _sharedSchemaRepo.ListAsync(cancellationToken);
|
||||||
|
return (IReadOnlyDictionary<string, string>)sharedSchemas
|
||||||
|
.ToDictionary(s => s.Name, s => s.SchemaJson, StringComparer.Ordinal);
|
||||||
|
});
|
||||||
Func<string, string?> resolveSchemaRef = name => schemaLibrary.GetValueOrDefault(name);
|
Func<string, string?> resolveSchemaRef = name => schemaLibrary.GetValueOrDefault(name);
|
||||||
|
|
||||||
// Validate. This is the deploy-gating path, so connection-binding completeness
|
// Validate. This is the deploy-gating path, so connection-binding completeness
|
||||||
@@ -180,24 +216,6 @@ public class FlatteningPipeline : IFlatteningPipeline
|
|||||||
new FlatteningPipelineResult(config, hash, validation));
|
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(
|
private async Task<IReadOnlyDictionary<int, DataConnection>> LoadDataConnectionsAsync(
|
||||||
int siteId,
|
int siteId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -23,11 +23,29 @@ public interface IFlatteningPipeline
|
|||||||
/// which need only the flattened config + revision hash, not a compile. Structural and
|
/// which need only the flattened config + revision hash, not a compile. Structural and
|
||||||
/// semantic validation still run in both cases.
|
/// semantic validation still run in both cases.
|
||||||
/// </param>
|
/// </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>
|
/// <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(
|
Task<Result<FlatteningPipelineResult>> FlattenAndValidateAsync(
|
||||||
int instanceId,
|
int instanceId,
|
||||||
CancellationToken cancellationToken = default,
|
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>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using Microsoft.Extensions.Options;
|
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.Interfaces.Transport;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
||||||
|
|
||||||
@@ -39,6 +41,12 @@ public static class ServiceCollectionExtensions
|
|||||||
ServiceDescriptor.Singleton<IValidateOptions<DeploymentManagerOptions>, DeploymentManagerOptionsValidator>());
|
ServiceDescriptor.Singleton<IValidateOptions<DeploymentManagerOptions>, DeploymentManagerOptionsValidator>());
|
||||||
services.AddSingleton<OperationLockManager>();
|
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
|
// Push-based deployment-status notification. Registered
|
||||||
// as a singleton so the scoped DeploymentService and the Central UI's
|
// as a singleton so the scoped DeploymentService and the Central UI's
|
||||||
// scoped Blazor page component share one instance — both run in the
|
// 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;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
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
|
/// deployed snapshot to decide staleness. Hosted in DeploymentManager so the
|
||||||
/// Transport component (which references only Commons + TemplateEngine) can probe
|
/// Transport component (which references only Commons + TemplateEngine) can probe
|
||||||
/// staleness without taking a DeploymentManager project reference.
|
/// 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>
|
/// </summary>
|
||||||
public sealed class StaleInstanceProbe : IStaleInstanceProbe
|
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 IFlatteningPipeline _flatteningPipeline;
|
||||||
|
private readonly ITemplateGraphWatermark _watermark;
|
||||||
|
|
||||||
/// <summary>Initializes a new <see cref="StaleInstanceProbe"/>.</summary>
|
/// <summary>Initializes a new <see cref="StaleInstanceProbe"/>.</summary>
|
||||||
/// <param name="flatteningPipeline">The deployment flattening pipeline used to recompute the current revision hash.</param>
|
/// <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));
|
_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 />
|
/// <inheritdoc />
|
||||||
public async Task<string?> GetCurrentRevisionHashAsync(int instanceId, CancellationToken cancellationToken = default)
|
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
|
// The pipeline returns a Result; a flatten failure (e.g. unresolvable
|
||||||
// template chain mid-import) yields null so the caller treats the
|
// template chain mid-import) yields null so the caller treats the
|
||||||
// instance as "staleness indeterminate" and skips it. Flattening reuses
|
// 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
|
// Staleness only needs the revision hash — skip the expensive Roslyn
|
||||||
// script-compilation stage (this probe is called per-instance across a whole
|
// script-compilation stage (this probe is called per-instance across a whole
|
||||||
// bundle import in BundleImporter.ComputeStaleInstanceIdsAsync).
|
// 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
|
var result = await _flatteningPipeline
|
||||||
.FlattenAndValidateAsync(instanceId, cancellationToken, validateScripts: false)
|
.FlattenAndValidateAsync(instanceId, cancellationToken, validateScripts: false, session)
|
||||||
.ConfigureAwait(false);
|
.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,
|
PreviewBundleCommand or ImportBundleCommand => AdminOnly,
|
||||||
|
|
||||||
// Deployer operations
|
// Deployer operations
|
||||||
CreateInstanceCommand or MgmtDeployInstanceCommand or MgmtEnableInstanceCommand
|
CreateInstanceCommand or MgmtDeployInstanceCommand or MgmtDeploySiteCommand or MgmtEnableInstanceCommand
|
||||||
or MgmtDisableInstanceCommand or MgmtDeleteInstanceCommand
|
or MgmtDisableInstanceCommand or MgmtDeleteInstanceCommand
|
||||||
or SetConnectionBindingsCommand or SetInstanceOverridesCommand or SetInstanceAreaCommand
|
or SetConnectionBindingsCommand or SetInstanceOverridesCommand or SetInstanceAreaCommand
|
||||||
or SetInstanceAlarmOverrideCommand or DeleteInstanceAlarmOverrideCommand
|
or SetInstanceAlarmOverrideCommand or DeleteInstanceAlarmOverrideCommand
|
||||||
@@ -339,6 +339,7 @@ public class ManagementActor : ReceiveActor
|
|||||||
GetInstanceCommand cmd => await HandleGetInstance(sp, cmd, user),
|
GetInstanceCommand cmd => await HandleGetInstance(sp, cmd, user),
|
||||||
CreateInstanceCommand cmd => await HandleCreateInstance(sp, cmd, user),
|
CreateInstanceCommand cmd => await HandleCreateInstance(sp, cmd, user),
|
||||||
MgmtDeployInstanceCommand cmd => await HandleDeployInstance(sp, cmd, user),
|
MgmtDeployInstanceCommand cmd => await HandleDeployInstance(sp, cmd, user),
|
||||||
|
MgmtDeploySiteCommand cmd => await HandleDeploySite(sp, cmd, user),
|
||||||
MgmtEnableInstanceCommand cmd => await HandleEnableInstance(sp, cmd, user),
|
MgmtEnableInstanceCommand cmd => await HandleEnableInstance(sp, cmd, user),
|
||||||
MgmtDisableInstanceCommand cmd => await HandleDisableInstance(sp, cmd, user),
|
MgmtDisableInstanceCommand cmd => await HandleDisableInstance(sp, cmd, user),
|
||||||
MgmtDeleteInstanceCommand cmd => await HandleDeleteInstance(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)
|
private static async Task<object?> HandleListTemplates(IServiceProvider sp, ListTemplatesCommand cmd)
|
||||||
{
|
{
|
||||||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
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
|
// 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);
|
: 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)
|
private static async Task<object?> HandleEnableInstance(IServiceProvider sp, MgmtEnableInstanceCommand cmd, AuthenticatedUser user)
|
||||||
{
|
{
|
||||||
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
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.");
|
$"Attribute '{attrName}' is locked and cannot be overridden. No overrides were applied.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var svc = sp.GetRequiredService<InstanceService>();
|
// Apply as ONE read + ONE commit. The previous loop called
|
||||||
var results = new List<InstanceAttributeOverride>();
|
// 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)
|
foreach (var (attrName, overrideValue) in cmd.Overrides)
|
||||||
{
|
{
|
||||||
var result = await svc.SetAttributeOverrideAsync(cmd.InstanceId, attrName, overrideValue, user.Username);
|
// attrsByName lookup is guaranteed to hit — the pre-validation loop
|
||||||
if (!result.IsSuccess) throw new ManagementCommandException(result.Error);
|
// above rejects the whole batch on an unknown or locked attribute.
|
||||||
results.Add(result.Value);
|
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;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2439,50 +2511,68 @@ public class ManagementActor : ReceiveActor
|
|||||||
{
|
{
|
||||||
var repo = sp.GetRequiredService<IDeploymentManagerRepository>();
|
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
|
// Instance-scoped query: enforce against the target instance's site
|
||||||
// before reading its deployment history.
|
// before reading its deployment history.
|
||||||
if (cmd.InstanceId.HasValue)
|
if (cmd.InstanceId.HasValue)
|
||||||
{
|
{
|
||||||
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId.Value);
|
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
|
// Unfiltered query: a site-scoped Deployment user must only see records
|
||||||
// for instances at sites within their scope. DeploymentRecord has no
|
// for instances at sites within their scope. DeploymentRecord has no
|
||||||
// SiteId, so resolve each record's instance to its site and filter
|
// SiteId, so the scope is resolved to the set of in-scope instance ids and
|
||||||
// (mirrors the HandleListInstances / HandleListSites filter pattern).
|
// pushed into the query as an id filter — rather than loading every record
|
||||||
var records = await repo.GetAllDeploymentRecordsAsync();
|
// 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))
|
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 permittedIds = new HashSet<string>(user.PermittedSiteIds);
|
||||||
var templateRepo = sp.GetRequiredService<ITemplateEngineRepository>();
|
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 allInstances = await templateRepo.GetAllInstancesAsync();
|
||||||
var instanceSiteLookup = new Dictionary<int, int?>(allInstances.Count);
|
var scopedInstanceIds = allInstances
|
||||||
foreach (var instance in allInstances)
|
.Where(i => permittedIds.Contains(i.SiteId.ToString()))
|
||||||
{
|
.Select(i => i.Id)
|
||||||
instanceSiteLookup[instance.Id] = instance.SiteId;
|
.ToArray();
|
||||||
}
|
|
||||||
|
|
||||||
var scoped = new List<DeploymentRecord>();
|
return await repo.QueryDeploymentSummariesAsync(null, status, scopedInstanceIds, skip, take);
|
||||||
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
|
// Audit Log handler
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ public static class ManagementEndpoints
|
|||||||
[
|
[
|
||||||
typeof(ImportBundleCommand), typeof(PreviewBundleCommand), typeof(ExportBundleCommand),
|
typeof(ImportBundleCommand), typeof(PreviewBundleCommand), typeof(ExportBundleCommand),
|
||||||
typeof(MgmtDeployArtifactsCommand), typeof(MgmtDeployInstanceCommand),
|
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>
|
/// <summary>
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ public class TemplateService
|
|||||||
// only invariant the move can break is two templates sharing a
|
// only invariant the move can break is two templates sharing a
|
||||||
// (FolderId, Name) at the destination, which the design's
|
// (FolderId, Name) at the destination, which the design's
|
||||||
// naming-collisions-are-design-time-errors rule forbids.
|
// 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 =>
|
var collision = allTemplates.FirstOrDefault(t =>
|
||||||
t.Id != templateId &&
|
t.Id != templateId &&
|
||||||
t.FolderId == newFolderId &&
|
t.FolderId == newFolderId &&
|
||||||
@@ -300,7 +300,7 @@ public class TemplateService
|
|||||||
attribute.TemplateId = templateId;
|
attribute.TemplateId = templateId;
|
||||||
|
|
||||||
// If inheriting, validate not trying to add a member that would collide
|
// 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 testTemplate = CloneTemplateWithNewAttribute(template, attribute);
|
||||||
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
|
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
|
||||||
if (collisions.Count > 0)
|
if (collisions.Count > 0)
|
||||||
@@ -352,7 +352,7 @@ public class TemplateService
|
|||||||
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
|
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
|
||||||
if (template?.ParentTemplateId != null)
|
if (template?.ParentTemplateId != null)
|
||||||
{
|
{
|
||||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
|
||||||
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
||||||
existing.Name, template.ParentTemplateId.Value, allTemplates);
|
existing.Name, template.ParentTemplateId.Value, allTemplates);
|
||||||
if (parentMember != null && parentMember.IsLocked)
|
if (parentMember != null && parentMember.IsLocked)
|
||||||
@@ -436,7 +436,7 @@ public class TemplateService
|
|||||||
var template = await _repository.GetTemplateByIdAsync(attribute.TemplateId, cancellationToken);
|
var template = await _repository.GetTemplateByIdAsync(attribute.TemplateId, cancellationToken);
|
||||||
if (template?.ParentTemplateId != null)
|
if (template?.ParentTemplateId != null)
|
||||||
{
|
{
|
||||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
|
||||||
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
||||||
attribute.Name, template.ParentTemplateId.Value, allTemplates);
|
attribute.Name, template.ParentTemplateId.Value, allTemplates);
|
||||||
if (parentMember != null)
|
if (parentMember != null)
|
||||||
@@ -533,7 +533,7 @@ public class TemplateService
|
|||||||
alarm.TemplateId = templateId;
|
alarm.TemplateId = templateId;
|
||||||
|
|
||||||
// Check collisions
|
// Check collisions
|
||||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
|
||||||
var testTemplate = CloneTemplateWithNewAlarm(template, alarm);
|
var testTemplate = CloneTemplateWithNewAlarm(template, alarm);
|
||||||
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
|
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
|
||||||
if (collisions.Count > 0)
|
if (collisions.Count > 0)
|
||||||
@@ -583,7 +583,7 @@ public class TemplateService
|
|||||||
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
|
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
|
||||||
if (template?.ParentTemplateId != null)
|
if (template?.ParentTemplateId != null)
|
||||||
{
|
{
|
||||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
|
||||||
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
||||||
existing.Name, template.ParentTemplateId.Value, allTemplates);
|
existing.Name, template.ParentTemplateId.Value, allTemplates);
|
||||||
if (parentMember != null && parentMember.IsLocked)
|
if (parentMember != null && parentMember.IsLocked)
|
||||||
@@ -656,7 +656,7 @@ public class TemplateService
|
|||||||
var template = await _repository.GetTemplateByIdAsync(alarm.TemplateId, cancellationToken);
|
var template = await _repository.GetTemplateByIdAsync(alarm.TemplateId, cancellationToken);
|
||||||
if (template?.ParentTemplateId != null)
|
if (template?.ParentTemplateId != null)
|
||||||
{
|
{
|
||||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
|
||||||
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
||||||
alarm.Name, template.ParentTemplateId.Value, allTemplates);
|
alarm.Name, template.ParentTemplateId.Value, allTemplates);
|
||||||
if (parentMember != null)
|
if (parentMember != null)
|
||||||
@@ -703,7 +703,7 @@ public class TemplateService
|
|||||||
script.TemplateId = templateId;
|
script.TemplateId = templateId;
|
||||||
|
|
||||||
// Check collisions
|
// Check collisions
|
||||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
|
||||||
var testTemplate = CloneTemplateWithNewScript(template, script);
|
var testTemplate = CloneTemplateWithNewScript(template, script);
|
||||||
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
|
var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
|
||||||
if (collisions.Count > 0)
|
if (collisions.Count > 0)
|
||||||
@@ -750,7 +750,7 @@ public class TemplateService
|
|||||||
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
|
var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
|
||||||
if (template?.ParentTemplateId != null)
|
if (template?.ParentTemplateId != null)
|
||||||
{
|
{
|
||||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
|
||||||
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
||||||
existing.Name, template.ParentTemplateId.Value, allTemplates);
|
existing.Name, template.ParentTemplateId.Value, allTemplates);
|
||||||
if (parentMember != null && parentMember.IsLocked)
|
if (parentMember != null && parentMember.IsLocked)
|
||||||
@@ -827,7 +827,7 @@ public class TemplateService
|
|||||||
var template = await _repository.GetTemplateByIdAsync(script.TemplateId, cancellationToken);
|
var template = await _repository.GetTemplateByIdAsync(script.TemplateId, cancellationToken);
|
||||||
if (template?.ParentTemplateId != null)
|
if (template?.ParentTemplateId != null)
|
||||||
{
|
{
|
||||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
|
||||||
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
var parentMember = TemplateResolver.FindMemberByCanonicalName(
|
||||||
script.Name, template.ParentTemplateId.Value, allTemplates);
|
script.Name, template.ParentTemplateId.Value, allTemplates);
|
||||||
if (parentMember != null)
|
if (parentMember != null)
|
||||||
@@ -888,7 +888,7 @@ public class TemplateService
|
|||||||
|
|
||||||
// Acyclicity is checked against the base, not the to-be-created derived template —
|
// 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.
|
// 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);
|
var cycleError = CycleDetector.DetectCompositionCycle(templateId, composedTemplateId, allTemplates);
|
||||||
if (cycleError != null)
|
if (cycleError != null)
|
||||||
return Result<TemplateComposition>.Failure(cycleError);
|
return Result<TemplateComposition>.Failure(cycleError);
|
||||||
@@ -1226,19 +1226,34 @@ public class TemplateService
|
|||||||
/// Propagates a base member change to every derived descendant by reconciling
|
/// Propagates a base member change to every derived descendant by reconciling
|
||||||
/// their inherited rows. Called automatically after a base member is added,
|
/// their inherited rows. Called automatically after a base member is added,
|
||||||
/// updated, or deleted. The base template itself is NOT reconciled — it is the
|
/// 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
|
/// author of the change. A no-op when the template has no descendants, which is
|
||||||
/// has no descendants, which is the common case.
|
/// the common case.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="templateId">The template whose member set changed.</param>
|
/// <param name="templateId">The template whose member set changed.</param>
|
||||||
/// <param name="user">Username for the audit row.</param>
|
/// <param name="user">Username for the audit row.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</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>
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public async Task ReconcileDescendantsAsync(
|
public async Task ReconcileDescendantsAsync(
|
||||||
int templateId,
|
int templateId,
|
||||||
string user,
|
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);
|
var descendants = GetDescendantIdsBreadthFirst(templateId, all);
|
||||||
if (descendants.Count == 0)
|
if (descendants.Count == 0)
|
||||||
return;
|
return;
|
||||||
@@ -1591,7 +1606,7 @@ public class TemplateService
|
|||||||
int templateId,
|
int templateId,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
|
||||||
return TemplateResolver.ResolveAllMembers(templateId, allTemplates);
|
return TemplateResolver.ResolveAllMembers(templateId, allTemplates);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1616,7 +1631,7 @@ public class TemplateService
|
|||||||
if (template == null)
|
if (template == null)
|
||||||
return Result<bool>.Failure($"Template with ID {templateId} not found.");
|
return Result<bool>.Failure($"Template with ID {templateId} not found.");
|
||||||
|
|
||||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
|
||||||
var members = TemplateResolver.ResolveAllMembers(templateId, allTemplates);
|
var members = TemplateResolver.ResolveAllMembers(templateId, allTemplates);
|
||||||
var member = members.FirstOrDefault(m => m.CanonicalName == canonicalName);
|
var member = members.FirstOrDefault(m => m.CanonicalName == canonicalName);
|
||||||
|
|
||||||
@@ -1647,7 +1662,7 @@ public class TemplateService
|
|||||||
if (template == null)
|
if (template == null)
|
||||||
return new[] { $"Template with ID {templateId} not found." };
|
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);
|
return CollisionDetector.DetectCollisions(template, allTemplates);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1669,7 +1684,7 @@ public class TemplateService
|
|||||||
int? proposedComposedTemplateId,
|
int? proposedComposedTemplateId,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken);
|
var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
|
||||||
|
|
||||||
if (proposedParentId.HasValue)
|
if (proposedParentId.HasValue)
|
||||||
{
|
{
|
||||||
@@ -1698,7 +1713,7 @@ public class TemplateService
|
|||||||
|
|
||||||
private async Task<string?> ValidateCollisionsAsync(Template template, CancellationToken cancellationToken)
|
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);
|
var collisions = CollisionDetector.DetectCollisions(template, allTemplates);
|
||||||
if (collisions.Count > 0)
|
if (collisions.Count > 0)
|
||||||
return string.Join(" ", collisions);
|
return string.Join(" ", collisions);
|
||||||
|
|||||||
@@ -30,25 +30,53 @@ namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
|
|||||||
/// </para>
|
/// </para>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Bounded at <see cref="MaxEntries"/> entries; on overflow the cache is simply
|
/// <b>Eviction is segmented, never wholesale.</b> Overflow used to
|
||||||
/// cleared (the verdict is cheap to recompute, so a coarse reset is safe and avoids
|
/// <c>Clear()</c> the whole cache, which reads as harmless ("the verdict is cheap
|
||||||
/// eviction bookkeeping). <see cref="Hits"/>/<see cref="Count"/>/<see cref="Clear"/>
|
/// to recompute") but is not: a recompute is a fresh Roslyn compile, and every
|
||||||
/// are exposed for tests and diagnostics.
|
/// 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>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ScriptCompileVerdictCache
|
public static class ScriptCompileVerdictCache
|
||||||
{
|
{
|
||||||
/// <summary>Upper bound on cached entries; the cache is cleared wholesale on overflow.</summary>
|
/// <summary>
|
||||||
private const int MaxEntries = 4096;
|
/// 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 _hits;
|
||||||
|
private static long _evictions;
|
||||||
|
|
||||||
/// <summary>Number of cache hits observed since the last <see cref="Clear"/>.</summary>
|
/// <summary>Number of cache hits observed since the last <see cref="Clear"/>.</summary>
|
||||||
public static long Hits => Interlocked.Read(ref _hits);
|
public static long Hits => Interlocked.Read(ref _hits);
|
||||||
|
|
||||||
/// <summary>Current number of cached verdicts.</summary>
|
/// <summary>
|
||||||
public static int Count => Cache.Count;
|
/// 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>
|
/// <summary>
|
||||||
/// Returns the cached verdict for the pair (<paramref name="surface"/>,
|
/// 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)));
|
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);
|
Interlocked.Increment(ref _hits);
|
||||||
return verdict;
|
return 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;
|
||||||
|
}
|
||||||
|
|
||||||
verdict = factory();
|
verdict = factory();
|
||||||
|
Store(key, verdict);
|
||||||
// Coarse bound: on overflow drop everything rather than track evictions.
|
|
||||||
if (Cache.Count >= MaxEntries)
|
|
||||||
Cache.Clear();
|
|
||||||
|
|
||||||
Cache[key] = verdict;
|
|
||||||
return verdict;
|
return verdict;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Clears all cached verdicts and resets the hit counter.</summary>
|
/// <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()
|
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 _hits, 0);
|
||||||
|
Interlocked.Exchange(ref _evictions, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,6 +141,22 @@ public class CommandTreeTests
|
|||||||
Assert.Contains("remove", subNames);
|
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]
|
[Fact]
|
||||||
public void InstanceNativeAlarmSource_HasSetAndClear()
|
public void InstanceNativeAlarmSource_HasSetAndClear()
|
||||||
{
|
{
|
||||||
@@ -323,6 +339,7 @@ public class CommandTreeTests
|
|||||||
[InlineData(typeof(SetInstanceOverridesCommand))]
|
[InlineData(typeof(SetInstanceOverridesCommand))]
|
||||||
[InlineData(typeof(DebugSnapshotCommand))]
|
[InlineData(typeof(DebugSnapshotCommand))]
|
||||||
[InlineData(typeof(MgmtDeployInstanceCommand))]
|
[InlineData(typeof(MgmtDeployInstanceCommand))]
|
||||||
|
[InlineData(typeof(MgmtDeploySiteCommand))]
|
||||||
[InlineData(typeof(QueryAuditLogCommand))]
|
[InlineData(typeof(QueryAuditLogCommand))]
|
||||||
[InlineData(typeof(ExportBundleCommand))]
|
[InlineData(typeof(ExportBundleCommand))]
|
||||||
[InlineData(typeof(PreviewBundleCommand))]
|
[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.Commons.Entities.Templates;
|
||||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||||
|
|
||||||
@@ -120,7 +121,7 @@ public class SplitQueryBehaviourTests : IDisposable
|
|||||||
public SplitQueryBehaviourTests()
|
public SplitQueryBehaviourTests()
|
||||||
{
|
{
|
||||||
_context = SqliteTestHelper.CreateInMemoryContext();
|
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||||
_repository = new TemplateEngineRepository(_context);
|
_repository = new TemplateEngineRepository(_context, new TemplateGraphWatermark());
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|||||||
+2
-1
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ public class TemplateEngineRepositoryTests : IDisposable
|
|||||||
_context = new ScadaBridgeDbContext(options);
|
_context = new ScadaBridgeDbContext(options);
|
||||||
_context.Database.OpenConnection();
|
_context.Database.OpenConnection();
|
||||||
_context.Database.EnsureCreated();
|
_context.Database.EnsureCreated();
|
||||||
_repository = new TemplateEngineRepository(_context);
|
_repository = new TemplateEngineRepository(_context, new TemplateGraphWatermark());
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
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.Commons.Types.Flattening;
|
||||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
|
||||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
|
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
|
||||||
|
|
||||||
@@ -75,7 +76,8 @@ public class DeploymentComparisonTests
|
|||||||
new FlatteningService(),
|
new FlatteningService(),
|
||||||
new ValidationService(),
|
new ValidationService(),
|
||||||
new RevisionHashService(),
|
new RevisionHashService(),
|
||||||
sharedSchemaRepo);
|
sharedSchemaRepo,
|
||||||
|
new TemplateGraphWatermark());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (ITemplateEngineRepository, ISiteRepository, ISharedSchemaRepository) ArrangeNonCompilingScript()
|
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.Commons.Types.Flattening;
|
||||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
|
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
|
||||||
|
|
||||||
@@ -641,15 +642,21 @@ public class DeploymentServiceTests : TestKit
|
|||||||
public async Task StaleInstanceProbe_RequestsValidateScriptsFalse()
|
public async Task StaleInstanceProbe_RequestsValidateScriptsFalse()
|
||||||
{
|
{
|
||||||
// The Transport stale-instance probe also only needs the hash — same skip.
|
// 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(
|
.Returns(Result<FlatteningPipelineResult>.Success(
|
||||||
new FlatteningPipelineResult(new FlattenedConfiguration(), "sha256:probe", ValidationResult.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);
|
var hash = await probe.GetCurrentRevisionHashAsync(5);
|
||||||
|
|
||||||
Assert.Equal("sha256:probe", hash);
|
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 ──
|
// ── 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -8,6 +8,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
|||||||
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
||||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
|
||||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
|
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
|
||||||
|
|
||||||
@@ -40,7 +41,8 @@ public class FlatteningPipelineConnectionBindingTests
|
|||||||
new FlatteningService(),
|
new FlatteningService(),
|
||||||
new ValidationService(),
|
new ValidationService(),
|
||||||
new RevisionHashService(),
|
new RevisionHashService(),
|
||||||
_sharedSchemaRepo);
|
_sharedSchemaRepo,
|
||||||
|
new TemplateGraphWatermark());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
+3
-1
@@ -7,6 +7,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
|||||||
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
||||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
|
||||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
|
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
|
||||||
|
|
||||||
@@ -40,7 +41,8 @@ public class FlatteningPipelineNativeAlarmCapabilityTests
|
|||||||
new FlatteningService(),
|
new FlatteningService(),
|
||||||
new ValidationService(),
|
new ValidationService(),
|
||||||
new RevisionHashService(),
|
new RevisionHashService(),
|
||||||
_sharedSchemaRepo);
|
_sharedSchemaRepo,
|
||||||
|
new TemplateGraphWatermark());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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.Interfaces.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
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.Flattening;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||||
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
||||||
using ZB.MOM.WW.ScadaBridge.ManagementService;
|
using ZB.MOM.WW.ScadaBridge.ManagementService;
|
||||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
||||||
@@ -242,13 +245,14 @@ public class ManagementActorTests : TestKit, IDisposable
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ListTemplatesCommand_ReturnsTemplateData()
|
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.
|
||||||
new("PumpTemplate") { Id = 1, Description = "Pump" },
|
_templateRepo.GetTemplateSummariesAsync(Arg.Any<int>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
|
||||||
new("ValveTemplate") { Id = 2, Description = "Valve" }
|
.Returns(new List<TemplateSummary>
|
||||||
};
|
{
|
||||||
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
new(1, "PumpTemplate", "Pump", null, null, false, null, 0, 0, 0, 0, 0),
|
||||||
.Returns(templates);
|
new(2, "ValveTemplate", "Valve", null, null, false, null, 0, 0, 0, 0, 0)
|
||||||
|
});
|
||||||
|
|
||||||
var actor = CreateActor();
|
var actor = CreateActor();
|
||||||
var envelope = Envelope(new ListTemplatesCommand());
|
var envelope = Envelope(new ListTemplatesCommand());
|
||||||
@@ -343,7 +347,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ListTemplatesCommand_WhenRepoThrows_ReturnsManagementError()
|
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"));
|
.ThrowsAsync(new InvalidOperationException("Database connection lost"));
|
||||||
|
|
||||||
var actor = CreateActor();
|
var actor = CreateActor();
|
||||||
@@ -1138,6 +1142,74 @@ public class ManagementActorTests : TestKit, IDisposable
|
|||||||
private static Commons.Entities.Deployment.DeploymentRecord DeploymentRecordFor(int instanceId) =>
|
private static Commons.Entities.Deployment.DeploymentRecord DeploymentRecordFor(int instanceId) =>
|
||||||
new("deploy-" + instanceId, "operator") { Id = instanceId, InstanceId = 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]
|
[Fact]
|
||||||
public void QueryDeployments_WithDesignRole_ReturnsUnauthorized()
|
public void QueryDeployments_WithDesignRole_ReturnsUnauthorized()
|
||||||
{
|
{
|
||||||
@@ -1154,11 +1226,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
|||||||
public void QueryDeployments_UnfilteredWithDeploymentRole_ReturnsAllRecords()
|
public void QueryDeployments_UnfilteredWithDeploymentRole_ReturnsAllRecords()
|
||||||
{
|
{
|
||||||
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
||||||
deployRepo.GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>())
|
StubDeploymentSummaryQuery(deployRepo, [1, 2]);
|
||||||
.Returns(new List<Commons.Entities.Deployment.DeploymentRecord>
|
|
||||||
{
|
|
||||||
DeploymentRecordFor(1), DeploymentRecordFor(2)
|
|
||||||
});
|
|
||||||
_services.AddScoped(_ => deployRepo);
|
_services.AddScoped(_ => deployRepo);
|
||||||
|
|
||||||
var actor = CreateActor();
|
var actor = CreateActor();
|
||||||
@@ -1175,8 +1243,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
|||||||
public void QueryDeployments_FilteredByInstanceId_ReturnsInstanceRecords()
|
public void QueryDeployments_FilteredByInstanceId_ReturnsInstanceRecords()
|
||||||
{
|
{
|
||||||
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
||||||
deployRepo.GetDeploymentsByInstanceIdAsync(5, Arg.Any<CancellationToken>())
|
StubDeploymentSummaryQuery(deployRepo, [5]);
|
||||||
.Returns(new List<Commons.Entities.Deployment.DeploymentRecord> { DeploymentRecordFor(5) });
|
|
||||||
_services.AddScoped(_ => deployRepo);
|
_services.AddScoped(_ => deployRepo);
|
||||||
|
|
||||||
var actor = CreateActor();
|
var actor = CreateActor();
|
||||||
@@ -1205,7 +1272,8 @@ public class ManagementActorTests : TestKit, IDisposable
|
|||||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||||
// The out-of-scope instance's deployment history must not be queried.
|
// 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]
|
[Fact]
|
||||||
@@ -1214,8 +1282,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
|||||||
_templateRepo.GetInstanceByIdAsync(5, Arg.Any<CancellationToken>())
|
_templateRepo.GetInstanceByIdAsync(5, Arg.Any<CancellationToken>())
|
||||||
.Returns(new Instance("Pump5") { Id = 5, SiteId = 1 });
|
.Returns(new Instance("Pump5") { Id = 5, SiteId = 1 });
|
||||||
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
||||||
deployRepo.GetDeploymentsByInstanceIdAsync(5, Arg.Any<CancellationToken>())
|
StubDeploymentSummaryQuery(deployRepo, [5]);
|
||||||
.Returns(new List<Commons.Entities.Deployment.DeploymentRecord> { DeploymentRecordFor(5) });
|
|
||||||
_services.AddScoped(_ => deployRepo);
|
_services.AddScoped(_ => deployRepo);
|
||||||
|
|
||||||
var actor = CreateActor();
|
var actor = CreateActor();
|
||||||
@@ -1240,11 +1307,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
|||||||
new("Pump2") { Id = 2, SiteId = 2 },
|
new("Pump2") { Id = 2, SiteId = 2 },
|
||||||
});
|
});
|
||||||
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
||||||
deployRepo.GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>())
|
StubDeploymentSummaryQuery(deployRepo, [1, 2]);
|
||||||
.Returns(new List<Commons.Entities.Deployment.DeploymentRecord>
|
|
||||||
{
|
|
||||||
DeploymentRecordFor(1), DeploymentRecordFor(2)
|
|
||||||
});
|
|
||||||
_services.AddScoped(_ => deployRepo);
|
_services.AddScoped(_ => deployRepo);
|
||||||
|
|
||||||
var actor = CreateActor();
|
var actor = CreateActor();
|
||||||
@@ -1276,12 +1339,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
|||||||
new("Pump3") { Id = 3, SiteId = 1 },
|
new("Pump3") { Id = 3, SiteId = 1 },
|
||||||
});
|
});
|
||||||
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
||||||
deployRepo.GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>())
|
StubDeploymentSummaryQuery(deployRepo, [1, 2, 3]);
|
||||||
.Returns(new List<Commons.Entities.Deployment.DeploymentRecord>
|
|
||||||
{
|
|
||||||
DeploymentRecordFor(1), DeploymentRecordFor(2), DeploymentRecordFor(3),
|
|
||||||
DeploymentRecordFor(1), DeploymentRecordFor(3) // duplicates: still no extra lookups
|
|
||||||
});
|
|
||||||
_services.AddScoped(_ => deployRepo);
|
_services.AddScoped(_ => deployRepo);
|
||||||
|
|
||||||
var actor = CreateActor();
|
var actor = CreateActor();
|
||||||
@@ -1300,11 +1358,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
|||||||
// Admin role bypasses site scoping even with PermittedSiteIds set.
|
// Admin role bypasses site scoping even with PermittedSiteIds set.
|
||||||
// (The user also holds Deployment so it passes the role gate.)
|
// (The user also holds Deployment so it passes the role gate.)
|
||||||
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
var deployRepo = Substitute.For<IDeploymentManagerRepository>();
|
||||||
deployRepo.GetAllDeploymentRecordsAsync(Arg.Any<CancellationToken>())
|
StubDeploymentSummaryQuery(deployRepo, [1, 2]);
|
||||||
.Returns(new List<Commons.Entities.Deployment.DeploymentRecord>
|
|
||||||
{
|
|
||||||
DeploymentRecordFor(1), DeploymentRecordFor(2)
|
|
||||||
});
|
|
||||||
_services.AddScoped(_ => deployRepo);
|
_services.AddScoped(_ => deployRepo);
|
||||||
|
|
||||||
var actor = CreateActor();
|
var actor = CreateActor();
|
||||||
@@ -1391,10 +1445,11 @@ public class ManagementActorTests : TestKit, IDisposable
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ListTemplates_SkipTake_ReturnsRequestedWindow()
|
public void ListTemplates_SkipTake_ReturnsRequestedWindow()
|
||||||
{
|
{
|
||||||
var templates = Enumerable.Range(1, 30)
|
// Paging moved into the repository (DB-side Skip/Take). The stub applies
|
||||||
.Select(i => new Template($"T-{i:D3}") { Id = i })
|
// the same window the database would, so this still pins that the
|
||||||
.ToList();
|
// command's Skip/Take actually reach the query — the bug being that the
|
||||||
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>()).Returns(templates);
|
// handler used to load everything and slice in memory.
|
||||||
|
StubTemplateSummaryPaging(30);
|
||||||
|
|
||||||
var actor = CreateActor();
|
var actor = CreateActor();
|
||||||
actor.Tell(Envelope(new ListTemplatesCommand(Skip: 10, Take: 5)));
|
actor.Tell(Envelope(new ListTemplatesCommand(Skip: 10, Take: 5)));
|
||||||
@@ -1410,10 +1465,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ListTemplates_DefaultTake_ReturnsAll()
|
public void ListTemplates_DefaultTake_ReturnsAll()
|
||||||
{
|
{
|
||||||
var templates = Enumerable.Range(1, 30)
|
StubTemplateSummaryPaging(30);
|
||||||
.Select(i => new Template($"T-{i:D3}") { Id = i })
|
|
||||||
.ToList();
|
|
||||||
_templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>()).Returns(templates);
|
|
||||||
|
|
||||||
var actor = CreateActor();
|
var actor = CreateActor();
|
||||||
actor.Tell(Envelope(new ListTemplatesCommand())); // Take = null => unlimited
|
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
|
// Repository throws an unanticipated fault carrying sensitive-looking
|
||||||
// detail. The raw text must NOT reach the caller.
|
// detail. The raw text must NOT reach the caller.
|
||||||
const string secret = "Server=db-internal-prod;constraint FK_secret";
|
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));
|
.ThrowsAsync(new InvalidProgramException(secret));
|
||||||
|
|
||||||
var actor = CreateActor();
|
var actor = CreateActor();
|
||||||
|
|||||||
@@ -118,6 +118,9 @@ public class RequiredRoleMatrixTests
|
|||||||
// ---- Deployer-only ----------------------------------------------------------
|
// ---- Deployer-only ----------------------------------------------------------
|
||||||
["CreateInstance"] = [Roles.Deployer],
|
["CreateInstance"] = [Roles.Deployer],
|
||||||
["MgmtDeployInstance"] = [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],
|
["MgmtEnableInstance"] = [Roles.Deployer],
|
||||||
["MgmtDisableInstance"] = [Roles.Deployer],
|
["MgmtDisableInstance"] = [Roles.Deployer],
|
||||||
["MgmtDeleteInstance"] = [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.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(parent);
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { parent });
|
.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");
|
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");
|
var result = await _service.CreateTemplateAsync("Child", null, 1, "admin");
|
||||||
|
|
||||||
Assert.True(result.IsSuccess);
|
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.GetAllTemplatesAsync(It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
_repoMock.Verify(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()), Times.Never);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -112,6 +118,8 @@ public class TemplateServiceTests
|
|||||||
.ReturnsAsync(new List<Instance>());
|
.ReturnsAsync(new List<Instance>());
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { template });
|
.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");
|
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 } });
|
.ReturnsAsync(new List<Instance> { new Instance("Pump1") { Id = 1, TemplateId = 1, SiteId = 1 } });
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { template });
|
.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");
|
var result = await _service.DeleteTemplateAsync(1, "admin");
|
||||||
|
|
||||||
@@ -145,6 +155,8 @@ public class TemplateServiceTests
|
|||||||
.ReturnsAsync(new List<Instance>());
|
.ReturnsAsync(new List<Instance>());
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { parent, child });
|
.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");
|
var result = await _service.DeleteTemplateAsync(1, "admin");
|
||||||
|
|
||||||
@@ -164,6 +176,8 @@ public class TemplateServiceTests
|
|||||||
.ReturnsAsync(new List<Instance>());
|
.ReturnsAsync(new List<Instance>());
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { moduleTemplate, composingTemplate });
|
.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");
|
var result = await _service.DeleteTemplateAsync(1, "admin");
|
||||||
|
|
||||||
@@ -191,6 +205,13 @@ public class TemplateServiceTests
|
|||||||
new Template("Child") { Id = 2, ParentTemplateId = 1 },
|
new Template("Child") { Id = 2, ParentTemplateId = 1 },
|
||||||
composer
|
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");
|
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.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { template });
|
.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 attr = new TemplateAttribute("Temperature") { DataType = DataType.Float, Value = "0.0" };
|
||||||
var result = await _service.AddAttributeAsync(1, attr, "admin");
|
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.GetTemplateByIdAsync(8, It.IsAny<CancellationToken>())).ReturnsAsync(child);
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { parent, child });
|
.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 attr = new TemplateAttribute("MoveInType") { DataType = DataType.String, Value = "" };
|
||||||
var result = await _service.AddAttributeAsync(8, attr, "admin");
|
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.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { template });
|
.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 attr = new TemplateAttribute("Temperature") { DataType = DataType.Int32, Value = "not-a-number" };
|
||||||
var result = await _service.AddAttributeAsync(1, attr, "admin");
|
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.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { template });
|
.ReturnsAsync(new List<Template> { template });
|
||||||
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Template> { template });
|
||||||
|
|
||||||
var attr = new TemplateAttribute("SetPoints")
|
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.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { template });
|
.ReturnsAsync(new List<Template> { template });
|
||||||
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Template> { template });
|
||||||
|
|
||||||
var alarm = new TemplateAlarm("HighTemp")
|
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.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { template });
|
.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 script = new TemplateScript("OnStart", "return true;") { TriggerType = "Startup" };
|
||||||
var result = await _service.AddScriptAsync(1, script, "admin");
|
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.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(moduleTemplate);
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { template, moduleTemplate });
|
.ReturnsAsync(new List<Template> { template, moduleTemplate });
|
||||||
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Template> { template, moduleTemplate });
|
||||||
|
|
||||||
Template? captured = null;
|
Template? captured = null;
|
||||||
_repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
|
_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.GetTemplateByIdAsync(11, It.IsAny<CancellationToken>())).ReturnsAsync(sensorProbe1);
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { pump, sensor, probe, sensorProbe1 });
|
.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>();
|
var captured = new List<Template>();
|
||||||
_repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
|
_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.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(moduleTemplate);
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { template, moduleTemplate, existing });
|
.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>();
|
var captured = new List<Template>();
|
||||||
_repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
|
_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.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { baseTemplate, derived });
|
.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 proposed = new TemplateAttribute("SetPoint") { Value = "99", DataType = DataType.Float, IsInherited = false };
|
||||||
var result = await _service.UpdateAttributeAsync(100, proposed, "admin");
|
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.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { baseTemplate, derived });
|
.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 proposed = new TemplateScript("Sample", "return 2;") { IsInherited = false };
|
||||||
var result = await _service.UpdateScriptAsync(200, proposed, "admin");
|
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.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(moduleTemplate);
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { template, moduleTemplate });
|
.ReturnsAsync(new List<Template> { template, moduleTemplate });
|
||||||
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Template> { template, moduleTemplate });
|
||||||
|
|
||||||
Template? captured = null;
|
Template? captured = null;
|
||||||
_repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
|
_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.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { baseTemplate, derived });
|
.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")
|
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.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { baseTemplate, derived });
|
.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")
|
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.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { baseTemplate, derived });
|
.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 proposed = new TemplateAttribute("SetPoint") { Value = "99", DataType = DataType.Float, IsInherited = false };
|
||||||
var result = await _service.UpdateAttributeAsync(100, proposed, "admin");
|
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.GetInstancesByTemplateIdAsync(5, It.IsAny<CancellationToken>())).ReturnsAsync(new List<Instance>());
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { baseTemplate, parent, derived });
|
.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");
|
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.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { template });
|
.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");
|
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.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(childTemplate);
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { parentTemplate, childTemplate });
|
.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")
|
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.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(childTemplate);
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { parentTemplate, childTemplate });
|
.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");
|
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.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(child);
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { child });
|
.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");
|
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.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(t);
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { t });
|
.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");
|
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.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(child);
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { child });
|
.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.
|
// 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");
|
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.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(templateA);
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { templateA, templateB, templateC });
|
.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");
|
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.GetFolderByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync(folder);
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { t });
|
.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");
|
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.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(t);
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { t });
|
.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");
|
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.GetFolderByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync(folder);
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { moving, existing });
|
.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");
|
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.GetFolderByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync(folder);
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { moving, unrelated });
|
.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");
|
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.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { template });
|
.ReturnsAsync(new List<Template> { template });
|
||||||
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Template> { template });
|
||||||
|
|
||||||
TemplateAttribute? added = null;
|
TemplateAttribute? added = null;
|
||||||
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
|
_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.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { template });
|
.ReturnsAsync(new List<Template> { template });
|
||||||
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Template> { template });
|
||||||
|
|
||||||
TemplateAlarm? added = null;
|
TemplateAlarm? added = null;
|
||||||
_repoMock.Setup(r => r.AddTemplateAlarmAsync(It.IsAny<TemplateAlarm>(), It.IsAny<CancellationToken>()))
|
_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.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { template });
|
.ReturnsAsync(new List<Template> { template });
|
||||||
|
_repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Template> { template });
|
||||||
|
|
||||||
TemplateScript? added = null;
|
TemplateScript? added = null;
|
||||||
_repoMock.Setup(r => r.AddTemplateScriptAsync(It.IsAny<TemplateScript>(), It.IsAny<CancellationToken>()))
|
_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>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { baseT, child });
|
.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>();
|
var added = new List<TemplateAttribute>();
|
||||||
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
|
||||||
.Callback<TemplateAttribute, CancellationToken>((a, _) => added.Add(a))
|
.Callback<TemplateAttribute, CancellationToken>((a, _) => added.Add(a))
|
||||||
@@ -1707,6 +1790,8 @@ public class TemplateServiceTests
|
|||||||
|
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { baseT, child });
|
.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>();
|
var deleted = new List<int>();
|
||||||
_repoMock.Setup(r => r.DeleteTemplateAttributeAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.DeleteTemplateAttributeAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||||
.Callback<int, CancellationToken>((id, _) => deleted.Add(id))
|
.Callback<int, CancellationToken>((id, _) => deleted.Add(id))
|
||||||
@@ -1731,6 +1816,8 @@ public class TemplateServiceTests
|
|||||||
|
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { baseT, child });
|
.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>();
|
var updated = new List<TemplateAttribute>();
|
||||||
_repoMock.Setup(r => r.UpdateTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.UpdateTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
|
||||||
.Callback<TemplateAttribute, CancellationToken>((a, _) => updated.Add(a))
|
.Callback<TemplateAttribute, CancellationToken>((a, _) => updated.Add(a))
|
||||||
@@ -1756,6 +1843,8 @@ public class TemplateServiceTests
|
|||||||
|
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { baseT, child });
|
.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");
|
var result = await _service.ResyncInheritedMembersAsync(8, "admin");
|
||||||
|
|
||||||
@@ -1781,6 +1870,8 @@ public class TemplateServiceTests
|
|||||||
|
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { baseT, left, right });
|
.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>();
|
var added = new List<TemplateAttribute>();
|
||||||
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
|
||||||
.Callback<TemplateAttribute, CancellationToken>((a, _) => added.Add(a))
|
.Callback<TemplateAttribute, CancellationToken>((a, _) => added.Add(a))
|
||||||
@@ -1809,6 +1900,8 @@ public class TemplateServiceTests
|
|||||||
|
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { baseT, child });
|
.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 addedScripts = new List<TemplateScript>();
|
||||||
var addedSources = new List<TemplateNativeAlarmSource>();
|
var addedSources = new List<TemplateNativeAlarmSource>();
|
||||||
_repoMock.Setup(r => r.AddTemplateScriptAsync(It.IsAny<TemplateScript>(), It.IsAny<CancellationToken>()))
|
_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>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { baseT, child });
|
.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>();
|
var added = new List<TemplateAttribute>();
|
||||||
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
|
||||||
.Callback<TemplateAttribute, CancellationToken>((a, _) => added.Add(a)).Returns(Task.CompletedTask);
|
.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.GetTemplateByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync(baseT);
|
||||||
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new List<Template> { baseT, child });
|
.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>();
|
var added = new List<TemplateAttribute>();
|
||||||
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
|
_repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
|
||||||
|
|||||||
+135
@@ -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,
|
/// authoritative behavior: bypasses the old substring scan missed are now caught,
|
||||||
/// and undefined symbols (which a structural scan could never see) fail compile.
|
/// and undefined symbols (which a structural scan could never see) fail compile.
|
||||||
/// </summary>
|
/// </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
|
public class ScriptCompilerTests
|
||||||
{
|
{
|
||||||
private readonly ScriptCompiler _sut = new();
|
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.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport;
|
using ZB.MOM.WW.ScadaBridge.Transport;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests;
|
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests;
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ public sealed class CompositionImportTests : IDisposable
|
|||||||
.UseInMemoryDatabase(dbName)
|
.UseInMemoryDatabase(dbName)
|
||||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
||||||
|
|
||||||
|
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
|
||||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
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.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport;
|
using ZB.MOM.WW.ScadaBridge.Transport;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests;
|
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests;
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ public sealed class ConflictResolutionTests : IDisposable
|
|||||||
.UseInMemoryDatabase(dbName)
|
.UseInMemoryDatabase(dbName)
|
||||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
||||||
|
|
||||||
|
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
|
||||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
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;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport.Encryption;
|
using ZB.MOM.WW.ScadaBridge.Transport.Encryption;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Export;
|
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
|
// ISiteRepository to walk the site/data-connection/instance closure, so it
|
||||||
// must be registered or the BuildServiceProvider-time graph resolution for
|
// must be registered or the BuildServiceProvider-time graph resolution for
|
||||||
// DependencyResolver fails.
|
// DependencyResolver fails.
|
||||||
|
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
|
||||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
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;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ public sealed class AreaTransportTests : IDisposable
|
|||||||
.UseInMemoryDatabase(dbName)
|
.UseInMemoryDatabase(dbName)
|
||||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
||||||
|
|
||||||
|
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
|
||||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||||
|
|||||||
+2
@@ -24,6 +24,7 @@ using ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
|||||||
using ZB.MOM.WW.ScadaBridge.Transport;
|
using ZB.MOM.WW.ScadaBridge.Transport;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
||||||
|
|
||||||
@@ -76,6 +77,7 @@ public sealed class BundleImporterApplyTests : IDisposable
|
|||||||
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
|
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
|
||||||
sp.GetRequiredService<IDataProtectionProvider>()));
|
sp.GetRequiredService<IDataProtectionProvider>()));
|
||||||
|
|
||||||
|
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
|
||||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||||
|
|||||||
+2
@@ -17,6 +17,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
|||||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport;
|
using ZB.MOM.WW.ScadaBridge.Transport;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ public sealed class BundleImporterPreviewTests : IDisposable
|
|||||||
var dbName = $"BundleImporterPreviewTests_{Guid.NewGuid()}";
|
var dbName = $"BundleImporterPreviewTests_{Guid.NewGuid()}";
|
||||||
services.AddDbContext<ScadaBridgeDbContext>(opts => opts.UseInMemoryDatabase(dbName));
|
services.AddDbContext<ScadaBridgeDbContext>(opts => opts.UseInMemoryDatabase(dbName));
|
||||||
|
|
||||||
|
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
|
||||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||||
|
|||||||
+2
@@ -18,6 +18,7 @@ using ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
|||||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport;
|
using ZB.MOM.WW.ScadaBridge.Transport;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
||||||
|
|
||||||
@@ -80,6 +81,7 @@ public sealed class BundleImporterRetryingStrategyTests : IDisposable
|
|||||||
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
|
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
|
||||||
sp.GetRequiredService<IDataProtectionProvider>()));
|
sp.GetRequiredService<IDataProtectionProvider>()));
|
||||||
|
|
||||||
|
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
|
||||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||||
|
|||||||
+2
@@ -19,6 +19,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
|||||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport;
|
using ZB.MOM.WW.ScadaBridge.Transport;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
||||||
|
|
||||||
@@ -81,6 +82,7 @@ public sealed class BundleImporterRollbackFailureTests : IDisposable
|
|||||||
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
|
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
|
||||||
sp.GetRequiredService<IDataProtectionProvider>()));
|
sp.GetRequiredService<IDataProtectionProvider>()));
|
||||||
|
|
||||||
|
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
|
||||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||||
|
|||||||
+2
@@ -12,6 +12,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
|||||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport;
|
using ZB.MOM.WW.ScadaBridge.Transport;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ public sealed class CreateMissingSiteRelationalTests : IDisposable
|
|||||||
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
|
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
|
||||||
sp.GetRequiredService<IDataProtectionProvider>()));
|
sp.GetRequiredService<IDataProtectionProvider>()));
|
||||||
|
|
||||||
|
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
|
||||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||||
|
|||||||
+2
@@ -13,6 +13,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
|||||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport;
|
using ZB.MOM.WW.ScadaBridge.Transport;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ public sealed class InheritanceImportTests : IDisposable
|
|||||||
.UseInMemoryDatabase(dbName)
|
.UseInMemoryDatabase(dbName)
|
||||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
||||||
|
|
||||||
|
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
|
||||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||||
|
|||||||
+2
@@ -17,6 +17,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
|||||||
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
||||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport;
|
using ZB.MOM.WW.ScadaBridge.Transport;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ public sealed class NativeAlarmSourceImportTests : IDisposable
|
|||||||
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
|
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
|
||||||
sp.GetRequiredService<IDataProtectionProvider>()));
|
sp.GetRequiredService<IDataProtectionProvider>()));
|
||||||
|
|
||||||
|
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
|
||||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||||
|
|||||||
+2
@@ -16,6 +16,7 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
|||||||
using ZB.MOM.WW.ScadaBridge.Transport;
|
using ZB.MOM.WW.ScadaBridge.Transport;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ public sealed class SiteInstanceImportTests : IDisposable
|
|||||||
.UseInMemoryDatabase(dbName)
|
.UseInMemoryDatabase(dbName)
|
||||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
||||||
|
|
||||||
|
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
|
||||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||||
|
|||||||
+2
@@ -16,6 +16,7 @@ using ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
|||||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport;
|
using ZB.MOM.WW.ScadaBridge.Transport;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ public sealed class TemplateScriptFidelityTests : IDisposable
|
|||||||
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
|
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
|
||||||
sp.GetRequiredService<IDataProtectionProvider>()));
|
sp.GetRequiredService<IDataProtectionProvider>()));
|
||||||
|
|
||||||
|
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
|
||||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
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.DeploymentManager;
|
||||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport;
|
using ZB.MOM.WW.ScadaBridge.Transport;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests;
|
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests;
|
||||||
|
|
||||||
@@ -91,6 +92,7 @@ public sealed class RoundTripEquivalenceTests : IDisposable
|
|||||||
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
|
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
|
||||||
sp.GetRequiredService<IDataProtectionProvider>()));
|
sp.GetRequiredService<IDataProtectionProvider>()));
|
||||||
|
|
||||||
|
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
|
||||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
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.ConfigurationDatabase.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport;
|
using ZB.MOM.WW.ScadaBridge.Transport;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests;
|
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests;
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ public sealed class RoundTripTests : IDisposable
|
|||||||
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
|
sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
|
||||||
sp.GetRequiredService<IDataProtectionProvider>()));
|
sp.GetRequiredService<IDataProtectionProvider>()));
|
||||||
|
|
||||||
|
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
|
||||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
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.ConfigurationDatabase.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport;
|
using ZB.MOM.WW.ScadaBridge.Transport;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests;
|
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests;
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ public sealed class SemanticValidatorImportTests : IDisposable
|
|||||||
.UseInMemoryDatabase(dbName)
|
.UseInMemoryDatabase(dbName)
|
||||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
||||||
|
|
||||||
|
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
|
||||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
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.ConfigurationDatabase.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport;
|
using ZB.MOM.WW.ScadaBridge.Transport;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests;
|
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests;
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ public sealed class ValidationFailureTests : IDisposable
|
|||||||
.UseInMemoryDatabase(dbName)
|
.UseInMemoryDatabase(dbName)
|
||||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
|
||||||
|
|
||||||
|
services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
|
||||||
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
|
||||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||||
|
|||||||
Reference in New Issue
Block a user