Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Deployment/IDeploymentArtifactCache.cs
T
Joseph Doherty a38a52b831 feat(localdb): chunked deployment-artifact cache over ILocalDb
128 KiB raw chunks base64-encoded into deployment_artifacts, with a per-cluster
pointer carrying a SHA-256 over the raw bytes. Reassembly verifies chunk count
AND the SHA before returning, so a partially replicated artifact reads back as a
clean miss rather than a silently truncated address space.

Adds GetCurrentUnkeyedAsync beyond the plan's interface: at the boot-failure seam
the actor cannot know its ClusterId (it is derivable only from an artifact you
already have, or from the central DB that is unreachable). Recon D-1.

The prune's 'deployment_id <> @DeploymentId' clause makes 'the pointer's target is
always present' structural rather than a side effect of clock resolution. Three
stores inside one timestamp tick fall through to a deployment_id tiebreak, and
since real ids are GUIDs that ordering is random - the row just written could lose,
leaving the pointer naming chunks that no longer exist. Verified red without the
clause.
2026-07-20 10:58:27 -04:00

69 lines
3.3 KiB
C#

namespace ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;
/// <summary>
/// Node-local durable cache of the deployment artifact this node last applied.
/// </summary>
/// <remarks>
/// <para>
/// Exists so a driver node can boot into its last-known-good address space when the central
/// configuration database is unreachable. Without it, a control-plane outage that outlives a
/// node restart leaves that node with no address space at all — the plant loses visibility
/// for a reason that has nothing to do with the plant.
/// </para>
/// </remarks>
public interface IDeploymentArtifactCache
{
/// <summary>
/// Persists <paramref name="artifact"/> as the cluster's current deployment.
/// </summary>
/// <remarks>
/// <para>
/// Idempotent per <paramref name="deploymentId"/>: re-storing the same deployment
/// replaces its chunks rather than accumulating orphans. Retention is bounded to the two
/// newest deployments per cluster, so a long-lived node cannot grow its local database
/// without limit.
/// </para>
/// </remarks>
Task StoreAsync(string clusterId, string deploymentId, string revisionHash,
byte[] artifact, CancellationToken ct = default);
/// <summary>
/// Reads the cached artifact for <paramref name="clusterId"/>, or <see langword="null"/>
/// when nothing is cached or the cached bytes fail their integrity check.
/// </summary>
/// <remarks>
/// <para>
/// A failed integrity check is deliberately indistinguishable from a miss. Booting a
/// plant from a truncated address space is strictly worse than booting from none: the
/// missing half looks like a deliberate configuration rather than an error.
/// </para>
/// </remarks>
Task<CachedDeploymentArtifact?> GetCurrentAsync(string clusterId, CancellationToken ct = default);
/// <summary>
/// Reads the single cached pointer without knowing the cluster id.
/// </summary>
/// <remarks>
/// <para>
/// The boot seam needs this. A node's cluster id is only derivable from an artifact it
/// has already loaded, or from the central database — which is precisely what is
/// unreachable in the scenario this cache exists to survive. So the cold path reads the
/// newest pointer unkeyed.
/// </para>
/// <para>
/// More than one pointer row means the node was re-homed between clusters. The newest
/// wins, but the choice is logged as a warning naming both clusters — a node silently
/// booting a neighbouring cluster's configuration is exactly the failure that must never
/// be quiet.
/// </para>
/// </remarks>
Task<CachedDeploymentArtifact?> GetCurrentUnkeyedAsync(CancellationToken ct = default);
}
/// <summary>
/// A deployment artifact recovered from the node-local cache, with the metadata needed to decide
/// whether it is still current once the control plane is reachable again.
/// </summary>
public sealed record CachedDeploymentArtifact(
string DeploymentId, string RevisionHash, byte[] Artifact, DateTimeOffset AppliedAtUtc);