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
@@ -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;
/// <summary>
/// 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()
{