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:
@@ -90,6 +90,13 @@ try
|
||||
// TransportOptions from ScadaBridge:Transport via BindConfiguration; no
|
||||
// explicit Configure needed.
|
||||
builder.Services.AddTransport();
|
||||
// Script-artifact invalidation bus (#05-T14): in-process, per-node pub/sub
|
||||
// for ScriptArtifactsChanged. The Transport bundle importer publishes
|
||||
// post-commit; the Inbound API compiled-handler cache subscribes as the
|
||||
// consumer (wired in plan 06). Central-only — it lives beside the importer.
|
||||
builder.Services.AddSingleton<
|
||||
ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Scripting.IScriptArtifactChangeBus,
|
||||
ZB.MOM.WW.ScadaBridge.Host.Services.InProcessScriptArtifactChangeBus>();
|
||||
// Audit Log — central node owns the AuditLogIngestActor singleton +
|
||||
// IAuditLogRepository. The site writer chain is still registered (lazy
|
||||
// singletons) but is never resolved on a central node.
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Scripting;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Services;
|
||||
|
||||
/// <summary>
|
||||
/// In-process, per-node <see cref="IScriptArtifactChangeBus"/>: a lock-free
|
||||
/// copy-on-write subscriber list. <see cref="Publish"/> snapshots the current
|
||||
/// handlers and invokes each, swallowing and logging any handler fault so a
|
||||
/// misbehaving consumer can never fault a producer's post-commit publish.
|
||||
/// <para>
|
||||
/// This is deliberately node-local — cross-node / failover propagation is the
|
||||
/// consumer's responsibility (content-hash / revision self-heal). See
|
||||
/// <c>docs/plans/2026-07-08-script-artifact-invalidation-contract.md</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class InProcessScriptArtifactChangeBus : IScriptArtifactChangeBus
|
||||
{
|
||||
private readonly Lock _writeLock = new();
|
||||
private readonly ILogger<InProcessScriptArtifactChangeBus> _logger;
|
||||
|
||||
// Copy-on-write: readers (Publish) take the reference without locking; writers
|
||||
// (Subscribe/unsubscribe) swap in a new immutable array under _writeLock.
|
||||
private volatile Action<ScriptArtifactsChanged>[] _handlers =
|
||||
Array.Empty<Action<ScriptArtifactsChanged>>();
|
||||
|
||||
/// <summary>Initializes the bus with a logger for swallowed handler faults.</summary>
|
||||
/// <param name="logger">Logger used to record subscriber handler exceptions.</param>
|
||||
public InProcessScriptArtifactChangeBus(ILogger<InProcessScriptArtifactChangeBus> logger) =>
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Publish(ScriptArtifactsChanged notification)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(notification);
|
||||
|
||||
// Snapshot without locking — the array is never mutated in place.
|
||||
var handlers = _handlers;
|
||||
foreach (var handler in handlers)
|
||||
{
|
||||
try
|
||||
{
|
||||
handler(notification);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A bad consumer must NEVER fault the producer's commit path.
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Script-artifact-change subscriber threw handling {Kind} ({Count} names) from {Source}; ignored.",
|
||||
notification.ArtifactKind,
|
||||
notification.Names.Count,
|
||||
notification.Source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDisposable Subscribe(Action<ScriptArtifactsChanged> handler)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(handler);
|
||||
|
||||
lock (_writeLock)
|
||||
{
|
||||
var updated = new Action<ScriptArtifactsChanged>[_handlers.Length + 1];
|
||||
Array.Copy(_handlers, updated, _handlers.Length);
|
||||
updated[^1] = handler;
|
||||
_handlers = updated;
|
||||
}
|
||||
|
||||
return new Subscription(this, handler);
|
||||
}
|
||||
|
||||
private void Unsubscribe(Action<ScriptArtifactsChanged> handler)
|
||||
{
|
||||
lock (_writeLock)
|
||||
{
|
||||
var index = Array.IndexOf(_handlers, handler);
|
||||
if (index < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var updated = new Action<ScriptArtifactsChanged>[_handlers.Length - 1];
|
||||
Array.Copy(_handlers, 0, updated, 0, index);
|
||||
Array.Copy(_handlers, index + 1, updated, index, _handlers.Length - index - 1);
|
||||
_handlers = updated;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Subscription : IDisposable
|
||||
{
|
||||
private readonly InProcessScriptArtifactChangeBus _bus;
|
||||
private Action<ScriptArtifactsChanged>? _handler;
|
||||
|
||||
public Subscription(InProcessScriptArtifactChangeBus bus, Action<ScriptArtifactsChanged> handler)
|
||||
{
|
||||
_bus = bus;
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Idempotent: only the first Dispose removes the handler.
|
||||
var handler = Interlocked.Exchange(ref _handler, null);
|
||||
if (handler is not null)
|
||||
{
|
||||
_bus.Unsubscribe(handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user