Commit Graph

469 Commits

Author SHA1 Message Date
Joseph Doherty c7d868c52e fix(transport): case-insensitive name lookups, batch preview lookups, correct flattener doc (ScadaBridge#93)
- ApplyTemplatesAsync/PreviewAsync template lookups use a case-insensitive,
  never-throwing comparer (SQL Server collation); base template wins over derived.
  Base names are already unique case-insensitively via the filtered unique index
  on Template.Name, derived templates are not covered by it.
- Preview reads database connections, notification lists and API methods with one
  GetAll query each instead of one by-name query per bundled row.
- Component-TemplateEngine.md: the flattener does not call GetAllTemplatesAsync;
  it loads chains via GetTemplateWithChildrenAsync.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 13:46:45 -05:00
Joseph Doherty 7153a1974e perf(transport): make large-bundle apply linear in the template rewire passes (ScadaBridge#47)
Measured first: a scratch scaling probe (seed -> export -> load/preview/apply
into a fresh target and over itself, in-memory provider, every repository
call counted, DetectChanges counted via ChangeTracker.DetectingAllChanges)
showed load, preview and the instance pass scale linearly (500 / 1000 / 2000
instances apply in 0.13 / 0.27 / 0.35 s), but the post-flush template
rewire passes were quadratic: 100 / 400 / 800 composing, derived templates
applied in 0.5 / 8.1 / 26.5 s, with DetectChanges running 5N+10 times and
GetTemplateWithChildrenAsync 2N+2 times.

Cause: ResolveAlarmScriptLinksAsync, ResolveCompositionEdgesAsync and
ResolveInheritanceEdgesAsync found each template with
_dbContext.Templates.Local.FirstOrDefault(...) - per template, and per
composition edge / base lookup. Every DbSet.Local access runs a full
DetectChanges over everything the import has staged. The first two passes
also re-read every template's children one query at a time (a split query
per call on SQL Server).

Fix (no bundle-format or semantic change):
- each pass indexes the tracked templates by name once
  (SnapshotLocalTemplatesByName; ordinal, first wins like FirstOrDefault).
  These passes never stage a template or rename one, so the index stays
  exact for the pass;
- the alarm and composition passes read their templates' children in one
  batched GetTemplatesWithChildrenAsync, matched back by the stub's id;
- the alarm pass only loads templates whose bundled alarms name an
  on-trigger script (the only ones it can rewire).

Result on the same probe: 100 / 400 / 800 templates apply in
0.13 / 0.62 / 0.64 s; DetectChanges is a constant 9; per-template children
reads 0, batched reads 1 (add) / 2 (overwrite).

Evidence: ImportApplyScalingTests (Transport.IntegrationTests) exports 5 and
40 composing, derived templates with alarm -> script links, imports each
into an empty target (Add) and again (Overwrite), and asserts the imported
inheritance / composition / alarm-script links, zero per-template children
reads, at most 3 batched reads, and an identical DetectChanges count for 5
and 40 templates. Fails on the old importer (12 per-template reads), and
with a per-DTO Local snapshot reintroduced in one pass (DetectChanges 51
vs 16). Transport unit (198) and integration (138) suites pass.

Not changed: preview's per-name lookups for database connections,
notification lists and API methods stay per-name - moving them to an
in-memory ordinal match could change name matching under a case-insensitive
SQL collation.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 13:30:27 -05:00
Joseph Doherty 51d2f6c906 refactor(deployment): injectable terminal-purge rate limiter; test the swallowed failure (ScadaBridge#90)
DeploymentService.TryPurgeTerminalDeploymentRecordsAsync rate-limited its
opportunistic retention sweep with a process-wide static tick field, so only
one test in the assembly could drive the sweep and its swallow-on-failure path
was untested.

The limit now lives in TerminalPurgeRateLimiter: a TimeProvider-driven
compare-and-swap claim (MinInterval 6 h) registered by AddDeploymentManager as
a DI singleton, so every scoped DeploymentService still shares one limit per
node. DeploymentService takes it as an optional trailing constructor parameter;
a hand-built instance gets its own limiter. The sweep's cutoff now comes from
the same clock. Behaviour is otherwise unchanged.

Evidence (TerminalPurgeRateLimiterTests):
- claim once per interval on an injected clock; 16 concurrent claimants ->
  exactly one wins;
- two services with their own limiters both sweep, two sharing one sweep once;
- cutoff = injected now - retention;
- a failing artifact batch is logged as a warning with the exception and
  swallowed, the already-purged terminal rows are still reported, the next
  call inside the interval does not retry and the first call after it does;
  a first-batch failure returns 0 and skips the artifact purge;
- AddDeploymentManager registers the limiter as a singleton shared across scopes.
Mutation-checked: a process-wide static limiter fails 5 tests (incl. the
existing artifact-purge test); narrowing the catch fails both failure tests.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 13:30:27 -05:00
Joseph Doherty 6070cc2b9d perf(templates): resync and resolved-members read only the chain/subtree (ScadaBridge#89)
The last two whole-library template reads are gone. Both fed the
inheritance-only TemplateInheritanceResolver, which walks one template's
parent chain and never follows compositions.

- TemplateService.ResyncInheritedMembersAsync (operator repair, CLI + editor)
  now reads #86's GetTemplateReconcileClosureAsync (derived subtree + the
  template's chain, tracked). When nothing derives from the template the
  closure is empty, so it falls back to the new tracked
  GetTemplateChainAsync - resyncing a leaf reads only its own chain.
- ManagementActor.HandleGetResolvedTemplateMembers now reads
  GetTemplateChainAsync(id, trackChanges: false): the chain alone, untracked,
  with script bodies (the resolver projects script code, so #66's body-free
  analysis closure would not be equivalent).

The chain walk is factored out of TemplateReconcileClosure into
CollectChainAsync (one load per level, stops on parent cycles and dangling
parents) and shared by both.

Evidence:
- TemplateChainReadTests (#86 fixture): for every template plus an unknown
  id, the closure-backed resync issues exactly the writes, audit row and
  result of the whole-graph resync with GetAllTemplatesAsync wired to throw;
  the resolver answers identically from the chain and the whole graph.
  17 of these fail against the old service; 13 fail with the leaf chain
  fallback removed (mutation-checked).
- TemplateChainRepositoryTests (SQLite, recorded SQL): a 4-deep chain loads
  4 templates / 8 attributes instead of 46 / 92, one split-query set per
  level, untracked on request and tracked for the resync.
- ResolvedTemplateMembersReadTests: the actor answers from the untracked
  chain read and never calls GetAllTemplatesAsync (fails on the old actor).

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 13:30:26 -05:00
Joseph Doherty d50433410d feat(health): surface pending-updates counts on the Health dashboard and CLI (ScadaBridge#63)
Health page gains PendingUpdatesHealthTiles (stale instances / sites with stale
artifacts, Deployer+Administrator only, site-scope narrowed, shared cache). New
GetPendingUpdates management command (Deployer) and 'health pending-updates' CLI
leaf. Flatten/cache computation untouched; the command reads
IPendingUpdatesEvaluator directly because the UI cache is not reachable from
ManagementService.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 13:18:54 -05:00
Joseph Doherty 004bf3cf06 feat(health): show site event-log/audit write and redaction failures on the Health dashboard (ScadaBridge#88)
New SiteWriteFailureBadges component renders warning badges for
SiteEventLogWriteFailures, SiteAuditWriteFailures and AuditRedactionFailure
when > 0 on online sites. The CLI 'health summary' already serialises the full
latest report, so these counters are visible there unchanged.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 13:13:58 -05:00
Joseph Doherty c29b99c63b perf(templates): descendant reconcile loads only the subtree + chain (ScadaBridge#86)
TemplateService.ReconcileDescendantsAsync - run after every base member
add/update/delete, template and native-alarm-source alike - loaded the whole
tracked template graph (every template, member and script body) on each call,
which after #66 was the dominant cost of those paths. It now calls the new
ITemplateEngineRepository.GetTemplateReconcileClosureAsync: the same tracked,
fully-included shape as GetAllTemplatesAsync, restricted to the edited
template's derived subtree plus its own inheritance chain. The reconcile is
inheritance-only (TemplateInheritanceResolver never follows compositions) and
every descendant's chain runs through the edited template, so that set is
everything it reads.

The walk (Commons TemplateReconcileClosure, the descendant-direction twin of
#66's TemplateAnalysisClosure) loads one children batch per subtree depth and
one batch per ancestor level, terminates on parent cycles and stops at
dangling parents. A template with no children - the common case - costs one
empty probe and loads nothing.

Evidence:
- TemplateReconcileClosureTests: for every template of a graph with a deep
  subtree, an ancestor above the edited template, a composed module,
  look-alike unrelated trees, a parent cycle and a dangling parent, the
  closure-backed reconcile issues exactly the writes (add/update/delete,
  audit, save) of the old whole-graph reconcile, and the resolver answers
  identically for every descendant. Fails with the old service (whole-graph
  load wired to throw) and with the ancestor walk removed (mutation-checked).
- TemplateReconcileClosureRepositoryTests (SQLite, recorded SQL): 5 templates
  / 10 attributes instead of 47 / 94, tracked with script bodies; a leaf costs
  one probe query and tracks nothing.
- TemplateServiceTests: attribute add + update propagate two levels down with
  GetAllTemplatesAsync wired to throw; existing propagation tests now run
  against the closure via ServeReconcileClosureFromFullGraph.

ResyncInheritedMembersAsync (operator action) still reads the whole graph.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 13:10:31 -05:00
Joseph Doherty 0eac956d25 feat(health): report permanently rejected site audit rows on the site health report (ScadaBridge#69)
SiteAuditBacklogSnapshot gains an optional trailing RejectedCount (gauge of
Rejected forward-state rows, read from the site store), carried on the wire as
SiteAuditBacklogSnapshotDto.rejected_count = 4 (additive; old sites read as 0,
old central ignores it). Health page shows an 'Audit rows rejected' badge.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 13:05:48 -05:00
Joseph Doherty 42443b4659 docs(site-runtime): IsMesReportable and AckTime now ride the wire (ScadaBridge#60, #84)
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 12:55:12 -05:00
Joseph Doherty e2b15bd520 fix(communication): evict idle SiteStreamGrpcClientFactory (site, endpoint) clients (ScadaBridge#67)
Each cached client records when it was last handed out; a periodic sweep (half of the new
CommunicationOptions.GrpcClientIdleTtl, default 30 min, 0 disables) disposes and removes entries
unused for the TTL that have no live subscription (SiteStreamGrpcClient.ActiveSubscriptionCount).
Lookup and the sweep's check-and-remove share one lock so a handed-out client is never disposed
under its caller; an evicted client is re-created on next GetOrCreate.
2026-11-09 12:07:28 -05:00
Joseph Doherty a5c3de63e9 fix(dcl): coerce scalar OPC UA writes to the node DataType (#58)
Codec-encoded scalars ("True", "42.5") reached RealOpcUaClient.WriteValuesAsync
as strings and were sent as String Variants, so a Boolean/Double node answered
BadTypeMismatch (0x80740000).

RealOpcUaClient now reads each written node's DataType attribute once per
session (batched, cached, cleared on connect/disconnect; derived types and
enumerations resolved via the supertype chain) and converts non-null scalar
values with the new OpcUaWriteCoercion table: Boolean, SByte..UInt64, Float,
Double, String, DateTime, Guid, all culture-invariant. Overflow, fractional
values to integer types and unparseable strings are refused client-side with
BadTypeMismatch and a message naming the value and target type - never a
rounded value. Arrays, null, unmapped types and nodes whose DataType read
fails pass through unchanged. WriteValueAsync now surfaces the outcome's own
status code instead of always BadNodeIdUnknown. MxGateway path untouched.

Tests: OpcUaWriteCoercionTests (coercion table incl. overflow, invalid and
culture-sensitive strings) and OpcUaWriteCoercionLiveTests (RequiresOpcUa:
String writes to Double/Boolean/UInt32 opc-plc nodes read back typed; reverting
the coercion reproduces 0x80740000). Component-DataConnectionLayer.md updated.
2026-11-06 16:08:33 -05:00
Joseph Doherty 0e89966da6 docs: point markdown backlogs at Gitea issues (status lives in Gitea) 2026-11-06 15:31:44 -05:00
Joseph Doherty 0e5402915b fix(centralui): pending-updates rail refreshes as soon as a deployment finishes — cache drops its snapshot on Success/Failed and signals the indicator, instead of serving the pre-deploy count for up to TTL + one poll 2026-10-22 17:11:18 -04:00
Joseph Doherty bcd087efe8 feat(centralui): Topology 'Deploy all…' on site and area nodes with confirm dialog, Force and summary toast (Gitea #40) 2026-10-22 14:27:26 -04:00
Joseph Doherty d71f61e71b feat(centralui): Topology pre-loads and expands the first layer (sites -> top-level areas) (Gitea #39) 2026-10-22 14:27:26 -04:00
Joseph Doherty f72c72c52f feat(managementservice): MgmtDeployArea — Deployer-only, scoped by the area's owning site (fail-closed on an unknown area), long-running like MgmtDeploySite (Gitea #40) 2026-10-22 14:27:26 -04:00
Joseph Doherty eb1c9ea7f9 feat(deploymentmanager): DeployAreaAsync bulk-deploys an area subtree through the shared DeploySiteAsync core; PreviewSubtreeDeployAsync returns confirm counts (Gitea #40)
DeploySiteAsync's body moves into a private core taking the site plus an
optional area-id filter; site deploy behaviour is unchanged. The subtree is
one in-memory ParentAreaId walk (cycle-safe) shared by the deploy and the
preview. DeploymentService gains an ITemplateEngineRepository dependency for
the area lookups.
2026-10-22 14:27:26 -04:00
Joseph Doherty 7be22d2ac2 fix(auditlog): truncate over-width SiteCalls.LastError on the cached-telemetry dual-write and the reconciliation pull (review 2026-10-21 follow-up) 2026-10-22 09:58:46 -04:00
Joseph Doherty 89d8568fdf fix(site-runtime): a faulted seed trigger-expression eval adopts nothing instead of replaying as a real false (review 2026-10-21 follow-up L4) 2026-10-22 09:58:46 -04:00
Joseph Doherty d646f816a4 fix(site-runtime): refuse an enable while a deploy pipeline owns the instance; revert is_enabled only for an invalid name (review 2026-10-21 follow-up L3) 2026-10-22 09:58:46 -04:00
Joseph Doherty e60c316f30 fix(dcl): queue a create whose actor name belongs to a still-terminating child instead of refusing it (review 2026-10-21 follow-up L2) 2026-10-22 09:58:46 -04:00
Joseph Doherty 530a096111 fix(auditlog): advance the served-rowid mark under the write lock so a purge clamp is never overwritten stale-high (review 2026-10-21 follow-up L1) 2026-10-22 09:58:46 -04:00
Joseph Doherty 1f87942ce3 fix(auditlog): reconciliation abandons only row-intrinsic failures and only when a sibling row inserted (review 2026-10-21 follow-up M1) 2026-10-22 09:58:46 -04:00
Joseph Doherty b67d736ac9 docs: reconcile stale cluster/security/notification docs; close register row 117, add rows 128-135 (review 2026-10-21 wave 2) 2026-10-22 09:51:52 -04:00
Joseph Doherty 88273693f9 fix(site-call-audit): reconciliation pull serves only un-acked tracking rows (node-local ack ledger), batched page upsert, prune removed sites' cursors (review 2026-10-21 06/PERF-2 + lane H follow-up) 2026-10-22 09:38:48 -04:00
Joseph Doherty e69be1654f fix(kpi-history): fold raw samples one hour at a time; only a unique-key violation is the benign fold race (review 2026-10-21 06/PERF-6, 08/P4, 08/C3) 2026-10-22 09:38:48 -04:00
Joseph Doherty e7fb71c2de fix(site-call-audit): KPI snapshots as single conditional-aggregate queries (review 2026-10-21 06/PERF-3, 08/P3) 2026-10-22 09:38:48 -04:00
Joseph Doherty 811a561885 fix(notification-outbox): per-attempt timestamps, Pending/Retrying split claim + sweep budget, SMTP MaxConcurrentConnections clamp (review 2026-10-21 06/COR-1, 06/PERF-5, 06/IMPL-2) 2026-10-22 09:38:48 -04:00
Joseph Doherty ebf4b96789 fix(notification-service): evict stale OAuth2 token cache entries after credential rotation (review 2026-10-21 06/MEM-3) 2026-10-22 09:38:48 -04:00
Joseph Doherty 7e08a1dbfb fix(transport): import reads the template library once per session, batches instance lookups, and expands stale instances to derived/composing templates over one flatten session (review 2026-10-21 04/P4, 08/P1, 04/C2) 2026-10-22 09:38:17 -04:00
Joseph Doherty 39025b28fa fix(transport): import rejects malformed site gRPC endpoints and non-actor-safe names as blockers, not mid-apply crashes (review 2026-10-21 03/R-1, lane-F ActorNameRules follow-up) 2026-10-22 09:38:17 -04:00
Joseph Doherty 04ac4c0491 fix(transport): export reads instance children once, site connections once per site, composed templates in batches (review 2026-10-21 04/P5) 2026-10-22 09:38:16 -04:00
Joseph Doherty 93fbd2e030 fix(transport): pin a compressed copy of the decrypted bundle on the import session, release the upload buffer early, document the footprint (review 2026-10-21 04/M2) 2026-10-22 09:38:16 -04:00
Joseph Doherty 55cebf2397 fix(transport): zero a bundle session's content when it expires, not only on apply/abandon (review 2026-10-21 04/C3) 2026-10-22 09:38:16 -04:00
Joseph Doherty fad08f7ce7 fix(transport): remove the never-read Transport:SchemaVersionMajor option (review 2026-10-21 04/U1, 08/O3) 2026-10-22 09:38:16 -04:00
Joseph Doherty 52c8a212a1 fix(management): reject native alarm source names that are not legal actor names (review 2026-10-21 lane F follow-up to 01/SR-C1) 2026-10-22 09:32:14 -04:00
Joseph Doherty df1f4efcfa fix(management): invalidate RoleMappingCache after role-mapping and scope-rule mutations commit (review 2026-10-21 lane K M1) 2026-10-22 09:32:14 -04:00
Joseph Doherty d495494074 fix(management): purge terminal PendingSecuredWrites rows on the list sweep after a retention window (review 2026-10-21 08/G3) 2026-10-22 09:32:14 -04:00
Joseph Doherty baab6ac78e fix(management): ListInstances pages, site-scopes and prefix-searches in SQL (review 2026-10-21 08/P2) 2026-10-22 09:32:14 -04:00
Joseph Doherty b0c863c6a1 fix(management): single-flight large /management bodies at the HTTP edge (409 IMPORT_IN_PROGRESS) and parse the command off the request stream (review 2026-10-21 05/M2) 2026-10-22 09:32:14 -04:00
Joseph Doherty 72f96bdbcb fix(management): compile an API method once per save — CompileForPublish replaces the validate pre-pass (review 2026-10-21 05/M1) 2026-10-22 09:32:14 -04:00
Joseph Doherty 36d57f11b3 fix(management): validate site gRPC addresses on create/update (review 2026-10-21 03/R-1) 2026-10-22 09:32:14 -04:00
Joseph Doherty 6f6534ac7e fix(site-runtime): artifact-apply watchdog + queued-apply failure on stop, boot-skip site event, enable revert, batch Ask slack (review 2026-10-21 lane A L1-L4)
L1: an artifact apply holding the slot past 10 min (ctor-overridable) logs an
Error site event naming it without releasing the slot; PostStop answers
queued applies Failed. L2: a boot-batch instance whose actor cannot be created
emits an Error site event and leaves the deployed set. L3: a failed enable
reverts the committed is_enabled (generation-bumped both ends). L4: the routed
SetAttributes batch Ask is the 30 s per-write bound plus 10 s mailbox slack.
2026-10-22 09:10:26 -04:00
Joseph Doherty 099b2221d6 fix(dcl): ASCII-only connection actor names, guarded ActorOf, and gate rejection of colliding connection names (review 2026-10-21 lane A M2)
ConnectionActorNames.Sanitize (shared by the DCL manager and the site deploy
gate) keeps ASCII letters/digits and -_.*+:@&=,!~';() only. The manager fails a
connection whose sanitised name is invalid or already used (Error + site
event) instead of restarting; DeployCompileValidator rejects a config whose
connection names collide after sanitising.
2026-10-22 09:10:26 -04:00
Joseph Doherty 3c11510c52 fix(site-runtime): seed republished overrides into Script Actors without firing scripts; alarms still evaluate (review 2026-10-21 01/SR-U1, decision M1)
HandleOverridesLoaded now sends children a site-local AttributeValueSeed
(stream still gets the AttributeValueChanged). ScriptActor updates its
snapshot, Conditional truth value and Expression last result (seed-only eval,
never fires); AlarmActor evaluates it as a normal change.
2026-10-22 09:10:26 -04:00
Joseph Doherty 999653a6b9 perf(site-runtime): warm the next startup batch during the stagger delay and compile a batch's distinct units in parallel (review 2026-10-21 01/SR-P3)
DeployCompileValidator.WarmCompileCache dedupes script bodies/trigger
expressions across a batch and compiles them with Parallel.ForEach (half the
cores). CreateInstanceActorBatch starts the next batch's warm as it arms the
stagger timer; creation order and delay are unchanged. The warm's parsed
configs are reused at creation.
2026-10-22 09:10:26 -04:00
Joseph Doherty dde22a3dd1 perf(site-runtime): deserialize a deploy's flattened config once and hand the parsed object down (review 2026-10-21 01/SR-P5)
The deploy compile warm parses once off-thread; the parsed FlattenedConfiguration
rides DeployCompileWarmed to the gate (new Validate(FlattenedConfiguration)
overload), the redeploy buffer, CreateInstanceActor's DCL ensure and a new
InstanceActor constructor overload. Stored-row paths parse once inside
CreateInstanceActor; an unparseable stored row still fails init via S6.
2026-10-22 09:10:26 -04:00
Joseph Doherty 960c35060c perf(site-runtime): instance reconcile sweep reads metadata only and fetches config_json just for the instances it repairs (review 2026-10-21 01/SR-P1)
Adds SiteStorageService.GetAllDeployedConfigMetadataAsync. The sweep decides
from the projection, then fetches single rows for its starts/restarts off-
thread and re-validates them (generation guard, revision match, pipeline/
running guards), releasing the per-revision claim of any repair that does
not act. Boot load unchanged.
2026-10-22 09:10:26 -04:00
Joseph Doherty c782744b07 perf(site-runtime): batch Route.To().GetAttributes/SetAttributes into one Instance Actor message (review 2026-10-21 01/SR-P4)
One Ask per attribute became one site-local InstanceGet/SetAttributesRequest
answered in a single pass, keeping the per-attribute GetAttributeResponse /
SetStaticAttributeResponse outcomes. The single-write path is refactored to a
Task<SetStaticAttributeResponse> outcome shared by both paths; static writes
and rejections still reply synchronously.
2026-10-22 09:10:26 -04:00
Joseph Doherty 35ffd025c3 fix(site-runtime): serialise DeployArtifactsCommand applies per site so an overlapping apply cannot delete a live artifact (review 2026-10-21 01/SR-C2)
Each apply upserts its full set then deletes everything else, off the actor
thread; two overlapping commands interleaved and deleted artifacts central
never removed. The Deployment Manager now runs one apply at a time, queuing
the rest in arrival order; the slot is released by a self-message that lands
behind the apply's own DCL push.
2026-10-22 09:10:26 -04:00