From a38a52b831a2beffd045c062f9c51afb7c258a0a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 20 Jul 2026 10:58:27 -0400 Subject: [PATCH] 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. --- .../Deployment/IDeploymentArtifactCache.cs | 2 +- .../LocalDbDeploymentArtifactCache.cs | 18 +++++--- .../LocalDbDeploymentArtifactCacheTests.cs | 41 ++++++++++++++++++- 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Deployment/IDeploymentArtifactCache.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Deployment/IDeploymentArtifactCache.cs index 32b841c6..7d4a1c74 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Deployment/IDeploymentArtifactCache.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Deployment/IDeploymentArtifactCache.cs @@ -1,4 +1,4 @@ -namespace ZB.MOM.WW.OtOpcUa.Runtime.Deployment; +namespace ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache; /// /// Node-local durable cache of the deployment artifact this node last applied. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Deployment/LocalDbDeploymentArtifactCache.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Deployment/LocalDbDeploymentArtifactCache.cs index e6b97cc2..a29c08e3 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Deployment/LocalDbDeploymentArtifactCache.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Deployment/LocalDbDeploymentArtifactCache.cs @@ -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; /// /// 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); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Deployment/LocalDbDeploymentArtifactCacheTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Deployment/LocalDbDeploymentArtifactCacheTests.cs index a2f34b20..86bb8154 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Deployment/LocalDbDeploymentArtifactCacheTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Deployment/LocalDbDeploymentArtifactCacheTests.cs @@ -6,9 +6,12 @@ using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.LocalDb; -using ZB.MOM.WW.OtOpcUa.Runtime.Deployment; +using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache; -namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Deployment; +// NOT "...Tests.Deployment", even though the folder is Deployment/. A child namespace named +// Deployment under ...Runtime.Tests shadows the Deployment EF entity type for every other file in +// this assembly — the same CS0118 trap that forced the production namespace's rename. +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.DeploymentCache; /// /// LocalDb Phase 1 (Task 6) — the chunked deployment-artifact cache. @@ -124,6 +127,40 @@ public sealed class LocalDbDeploymentArtifactCacheTests : IDisposable cached.DeploymentId.ShouldBe("dep-3"); } + [Fact] + public async Task Store_NeverPrunesTheDeploymentThePointerNames() + { + // Regression: the prune ranks survivors by cached_at_utc and falls through to a + // deployment_id tiebreak. Real deployment ids are GUIDs, so that tiebreak is effectively + // random — the deployment just written could lose and have its chunks deleted while the + // pointer still named it. The result is a cache that misses on every boot until the next + // deploy: silent, permanent, and precisely the outage this cache exists to survive. + // + // Rather than trying to race three stores into one clock tick, this forces the general + // case: the two older deployments are back-dated INTO THE FUTURE so the newest store loses + // the ranking outright. The invariant must hold regardless of timestamps. + var cache = CreateCache(); + var newest = RandomArtifact(1024); + + await cache.StoreAsync(ClusterA, "dep-old-1", "rev-1", RandomArtifact(1024)); + await cache.StoreAsync(ClusterA, "dep-old-2", "rev-2", RandomArtifact(1024)); + + await _db.ExecuteAsync( + "UPDATE deployment_artifacts SET cached_at_utc = @Future", + new { Future = "2099-01-01T00:00:00.0000000Z" }); + + await cache.StoreAsync(ClusterA, "dep-new", "rev-3", newest); + + // The pointer names dep-new, so dep-new's chunks must still be there... + (await CountChunksAsync("dep-new")).ShouldBeGreaterThan(0); + + // ...and it must actually read back, which is the behaviour that matters. + var cached = await cache.GetCurrentAsync(ClusterA); + cached.ShouldNotBeNull(); + cached.DeploymentId.ShouldBe("dep-new"); + cached.Artifact.ShouldBe(newest); + } + [Fact] public async Task GetCurrent_ReturnsNullWhenAChunkIsCorrupt() {