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.
This commit is contained in:
Joseph Doherty
2026-07-20 10:58:27 -04:00
parent 2bae1b4c9f
commit a38a52b831
3 changed files with 53 additions and 8 deletions
@@ -1,4 +1,4 @@
namespace ZB.MOM.WW.OtOpcUa.Runtime.Deployment;
namespace ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;
/// <summary>
/// Node-local durable cache of the deployment artifact this node last applied.
@@ -3,7 +3,7 @@ using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.LocalDb;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Deployment;
namespace ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;
/// <summary>
/// <see cref="IDeploymentArtifactCache"/> backed by the replicated LocalDb deployment-cache
@@ -125,13 +125,21 @@ public sealed class LocalDbDeploymentArtifactCache : IDeploymentArtifactCache
},
ct);
// Prune inside the same transaction so the cache is never briefly unbounded. Ordering by
// deployment_id as a tiebreak keeps the survivor set deterministic when two stores land in
// the same timestamp tick.
// Prune inside the same transaction so the cache is never briefly unbounded.
//
// The `deployment_id <> @DeploymentId` clause is load-bearing, not belt-and-braces: it makes
// "the deployment the pointer names is always present" a structural invariant instead of a
// consequence of clock resolution. Without it, three stores landing inside one timestamp
// tick fall through to the `deployment_id DESC` tiebreak — and since real deployment ids are
// GUIDs, that ordering is effectively random, so the row just written can lose. The pointer
// would then name chunks that no longer exist, which reads back as a cache miss on every
// subsequent boot until the next deploy overwrites it. Silent and permanent: exactly the
// failure this cache exists to prevent.
await tx.ExecuteAsync(
$"""
DELETE FROM deployment_artifacts
WHERE cluster_id = @ClusterId
AND deployment_id <> @DeploymentId
AND deployment_id NOT IN (
SELECT deployment_id
FROM deployment_artifacts
@@ -141,7 +149,7 @@ public sealed class LocalDbDeploymentArtifactCache : IDeploymentArtifactCache
LIMIT {RetainedDeployments}
)
""",
new { ClusterId = clusterId },
new { ClusterId = clusterId, DeploymentId = deploymentId },
ct);
await tx.CommitAsync(ct);