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:
+39
-2
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user