feat(commons/transport): ScriptArtifactsChanged invalidation contract + post-commit publish from bundle import (consumer lands in plan 06)
New Commons seam: ScriptArtifactsChanged record + ScriptArtifactKinds + the IScriptArtifactChangeBus pub/sub interface. Host ships InProcessScriptArtifactChangeBus (lock-free copy-on-write, swallow-and-log per subscriber) registered as a central-role singleton. BundleImporter gains an optional IScriptArtifactChangeBus? and, AFTER tx.CommitAsync, publishes one notification per script-bearing kind (ApiMethod/ SharedScript/Template) whose resolution was Overwrite/Rename, using post-resolution names — fully guarded so a bad subscriber can't fail a committed import. Contract + plan-06 handoff table documented. No consumer yet (additive; publisher is correct). Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
@@ -12,8 +12,10 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Scripting;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Scripting;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
@@ -80,6 +82,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
// in a host that registers Transport without DeploymentManager — staleness is
|
||||
// then best-effort empty (informational only, never gates the import).
|
||||
private readonly IStaleInstanceProbe? _staleInstanceProbe;
|
||||
private readonly IScriptArtifactChangeBus? _scriptArtifactChangeBus;
|
||||
private readonly ILogger<BundleImporter>? _logger;
|
||||
|
||||
/// <summary>
|
||||
@@ -103,6 +106,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
/// <param name="dbContext">EF Core context used to commit the import transaction.</param>
|
||||
/// <param name="semanticValidator">Validates template references before applying.</param>
|
||||
/// <param name="staleInstanceProbe">Optional: recomputes a deployed instance's current revision hash so the importer can enumerate instances stale-ed by a template overwrite. Null when no flattening pipeline is registered (Transport-without-DeploymentManager hosts) — staleness is then skipped.</param>
|
||||
/// <param name="scriptArtifactChangeBus">Optional: node-local bus notified AFTER a successful commit that script-bearing artifacts (ApiMethod / SharedScript / Template) were overwritten or renamed, so compiled-handler caches can invalidate. Null on hosts that don't register it (staleness/invalidation then relies on the consumer's own self-heal).</param>
|
||||
/// <param name="logger">Optional logger.</param>
|
||||
public BundleImporter(
|
||||
BundleSerializer bundleSerializer,
|
||||
@@ -123,6 +127,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
ScadaBridgeDbContext dbContext,
|
||||
SemanticValidator semanticValidator,
|
||||
IStaleInstanceProbe? staleInstanceProbe = null,
|
||||
IScriptArtifactChangeBus? scriptArtifactChangeBus = null,
|
||||
ILogger<BundleImporter>? logger = null)
|
||||
{
|
||||
_bundleSerializer = bundleSerializer ?? throw new ArgumentNullException(nameof(bundleSerializer));
|
||||
@@ -143,6 +148,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||
_semanticValidator = semanticValidator ?? throw new ArgumentNullException(nameof(semanticValidator));
|
||||
_staleInstanceProbe = staleInstanceProbe;
|
||||
_scriptArtifactChangeBus = scriptArtifactChangeBus;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -1112,6 +1118,14 @@ public sealed class BundleImporter : IBundleImporter
|
||||
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
await tx.CommitAsync(ct).ConfigureAwait(false);
|
||||
|
||||
// #05-T14: the write is now durable — advise any node-local compiled-
|
||||
// artifact cache that script-bearing artifacts changed so it can
|
||||
// invalidate by name. Published AFTER commit (never inside the
|
||||
// transaction — a notification for a rolled-back write is worse than a
|
||||
// missed one) and defensively guarded so a bad subscriber can never
|
||||
// turn a committed import into a reported failure.
|
||||
PublishScriptArtifactChanges(resolutions);
|
||||
|
||||
// T-007: zero out the decrypted plaintext BEFORE remove so any
|
||||
// caller-held reference (e.g. the Razor page that built the
|
||||
// ImportPreview) sees the cleared buffer too. Remove drops the
|
||||
@@ -1537,6 +1551,57 @@ public sealed class BundleImporter : IBundleImporter
|
||||
/// will still fall through to the unresolved-audit path — call sites are
|
||||
/// not rewritten in v1.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// #05-T14 — publishes one <see cref="ScriptArtifactsChanged"/> per script-bearing
|
||||
/// artifact kind (ApiMethod / SharedScript / Template) whose resolution was an
|
||||
/// Overwrite or Rename, using the post-resolution (renamed) names. Adds are
|
||||
/// excluded — nothing is cached under a brand-new name yet. Over-approximation is
|
||||
/// safe (an Overwrite that actually fell through to Add just triggers a harmless
|
||||
/// consumer re-check), which matches the bus's advisory, at-least-once contract.
|
||||
/// Fully guarded: never throws (it runs after the transaction has committed).
|
||||
/// </summary>
|
||||
private void PublishScriptArtifactChanges(IReadOnlyList<ImportResolution> resolutions)
|
||||
{
|
||||
if (_scriptArtifactChangeBus is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
PublishKind(resolutions, "ApiMethod", ScriptArtifactKinds.ApiMethod);
|
||||
PublishKind(resolutions, "SharedScript", ScriptArtifactKinds.SharedScript);
|
||||
PublishKind(resolutions, "Template", ScriptArtifactKinds.Template);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// The import already committed — a publish fault must never surface as
|
||||
// an import failure. Swallow-and-log (the bus itself also guards
|
||||
// per-subscriber, so this only catches pathological cases).
|
||||
_logger?.LogError(ex, "Publishing script-artifact-change notifications after a committed bundle import failed; ignored.");
|
||||
}
|
||||
|
||||
void PublishKind(IReadOnlyList<ImportResolution> all, string entityType, string kind)
|
||||
{
|
||||
var names = all
|
||||
.Where(r => string.Equals(r.EntityType, entityType, StringComparison.Ordinal)
|
||||
&& (r.Action is ResolutionAction.Overwrite or ResolutionAction.Rename))
|
||||
.Select(r => r.Action == ResolutionAction.Rename ? r.RenameTo ?? r.Name : r.Name)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
if (names.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_scriptArtifactChangeBus!.Publish(new ScriptArtifactsChanged(
|
||||
ArtifactKind: kind,
|
||||
Names: names,
|
||||
Source: "BundleImport",
|
||||
OccurredAtUtc: _timeProvider.GetUtcNow()));
|
||||
}
|
||||
}
|
||||
|
||||
private static Template BuildTemplate(TemplateDto dto, string? overrideName)
|
||||
{
|
||||
var t = new Template(overrideName ?? dto.Name) { Description = dto.Description };
|
||||
|
||||
Reference in New Issue
Block a user