Skip to content

Commit 92c826e

Browse files
committed
Checkpoint all C1 controls and fresh due attempts
Full working-tree checkpoint requested by the owner. Release build79f failed with compiler and analyzer errors; C1 RF3 and fresh due runtime acceptance remain unverified. Continue repairs and Aspire-owned verification from this committed source.
1 parent fb586bf commit 92c826e

78 files changed

Lines changed: 5466 additions & 65 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎docs/Features/ClusterRouting/NativeCqrsRequestV2.md‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -683,3 +683,70 @@ claim log privacy from filtered diagnostics or report remote-voter death.
683683
Root runs the two actual Aspire RF3 cases and required exact-source Linux gates;
684684
source alone cannot close these criteria. Existing ADR-034/058 and C1 control
685685
contracts cover this test surface without public/format/topology changes.
686+
687+
### C1 guarded offline outcome observation
688+
689+
TASK-CRS-C1-OUTCOME-INSPECTION supplies the missing native-store oracle for
690+
AC-CRS-004/005. Root accepts the existing guarded current-format recovery open
691+
after complete RF3 shutdown. This is a logical canonical-state observation;
692+
native journal recovery may replay, truncate an incomplete tail and flush.
693+
It is not a byte-preserving forensic reader, format converter, power-loss test
694+
or public command-outcome API. Existing ADR-034/058 and the guarded-store
695+
ownership contract cover this test-only addition; persisted records and
696+
generated native serializer contracts remain unchanged.
697+
698+
1. Add the distinct CrashHost mode `c1-outcome-inspect`. Its closed V1 private
699+
UTF-8 input contains only `Version`, `Directory`, `ExpectedNodeId`,
700+
`Incarnation`, `PrincipalId` and `CommandId`. Reject unknown/duplicate/missing
701+
or null fields, invalid UTF-8, trailing content, empty IDs, non-canonical
702+
paths and principal identifiers over 256 UTF-8 bytes. Bound input to 8192
703+
bytes and JSON depth to four before the native existing-store open.
704+
2. Open only through `ZoneTreeExistingStore.Open`, with the supplied observed
705+
node ID and same-wave incarnation. Do not bootstrap, create directories,
706+
add a cache/fault observer, decode keys/records independently, use reflection
707+
or call a format migration. Construct the normal DatabaseEngine with its
708+
persisted-store policy implementation and call `Outcome(principal,id)`.
709+
The result oracle is only null versus non-null.
710+
3. On successful native close, emit a closed V1 receipt containing only
711+
`Version`, `NodeId`, `Incarnation`, `FormatVersion`, `Position` and
712+
`OutcomePresent`, bounded to 4096 UTF-8 bytes. Never emit a path, principal,
713+
command, result content, credential or exception message/data/stack. Invalid
714+
input or failed open/read/close exits with fixed code 2 and no receipt.
715+
Preserve original failures internally and always close the owned native
716+
store; successful opening alone is insufficient.
717+
4. Root joins the additive dispatcher, project reference and internal friend
718+
metadata. The existing guard inspector and recovery modes stay exact. A
719+
parent invoked by the Aspire-owned runner holds the actual outer
720+
`node.owner.lock` through the original child exit, bounded pipe drains and
721+
process-handle close. The child acquires the canonical inner owner lock;
722+
the parent must not hold that same inner lock.
723+
5. The two held-authority scenarios capture actual node status IDs and the
724+
wave incarnation while the real cluster runs. Before revocation, a real
725+
caller command writes a separate positive-control document and records its
726+
command ID. Capture the no-effect baseline after that control. After the
727+
original held request, callbacks, public callers, diagnostics and all
728+
Aspire resources have joined and every owner-lock check passes, inspect the
729+
held and positive-control IDs on each actual node store. Require absence of
730+
the held outcome on all three nodes and presence of the positive control on
731+
at least the acknowledged quorum of two. Do not assume every follower had
732+
applied the latest command merely because the client obtained an ACK.
733+
6. Add actual existing-store process regressions for present/absent outcomes,
734+
wrong node/incarnation fail-closed behavior and strict malformed input.
735+
Retain the original child and cleanup failures. Execute these through the
736+
same Aspire unit/scalar caller. Root then runs the two actual RF3 authority
737+
cases and exact-source Linux gates; private code or local mechanism tests
738+
do not qualify AC-CRS-004/005 by themselves.
739+
740+
Agent graph: cluster_wave Luna/high owns only new CrashHost ClusterRouting
741+
Contracts/Serialization/Helpers files prefixed C1OutcomeInspection, privately
742+
against exact absent bases. lifecycle_wave Luna/high owns new CrashHost
743+
ClusterRouting Processes files prefixed C1OutcomeInspection for one shared
744+
bounded parent lifetime and new UnitTests ClusterRouting Cases/Helpers files
745+
with that prefix. Unit and RF3 callers reuse the child DTOs and that one parent
746+
lifetime; do not duplicate either. Parent execution and cleanup bounds are 30
747+
and 15 seconds respectively; neither bound substitutes a completed wrapper for
748+
joining its original child/pipe tasks. Root owns shared dispatch, metadata,
749+
RF3 status capture, positive-control/cleanup ordering and the parent-call join.
750+
Workers must not edit the checkout or start builds/tests/processes/Git; deliver
751+
complete private source, patch, hashes and self-review. Root reads every byte,
752+
integrates, runs the gates and commits the complete stage scope.

‎src/KeyLoad.AppHost/Features/ClusterReplication/Resources/ClusterResources.cs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,9 @@ internal static IResourceBuilder<ContainerResource>[] Add(IDistributedApplicatio
5050
var nodeNames = ReadNodeNames(benchmarkNodeCount);
5151
ClusterProfileStore.Validate(profile);
5252
var root = Path.GetFullPath(dataRoot);
53-
ClusterProfileStore.PrepareDirectory(root);
5453
var images = ProtocolCohortImages.Read(builder, ephemeral, benchmarkNodeCount);
54+
var probes = RequestCqrsProbeProfile.Read(builder, root, ephemeral, benchmarkNodeCount, images);
55+
ClusterProfileStore.PrepareDirectory(root);
5556
var signing = builder.AddParameter(SigningParameter, profile.SigningKey, secret: true);
5657
var peer = builder.AddParameter(PeerParameter, profile.PeerSecret, secret: true);
5758
var admin = builder.AddParameter(AdminParameter, profile.AdminKey, secret: true);
@@ -82,6 +83,7 @@ internal static IResourceBuilder<ContainerResource>[] Add(IDistributedApplicatio
8283
.WithEnvironment(HttpPortEnvironment, HttpPort.ToString(CultureInfo.InvariantCulture))
8384
.WithHttpHealthCheck(ReadyPath, endpointName: HttpEndpoint);
8485
ClusterResourceSettings.Apply(builder, resource, containerUser);
86+
probes?.Apply(resource, name);
8587
ApplyPeers(resource, nodeNames, benchmarkNodeCount.HasValue);
8688
nodes[index] = resource;
8789
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
namespace KeyLoad.AppHost.Features.ClusterRouting;
2+
3+
internal sealed record RequestCqrsProbeOwnerRecord(int Version, string Kind, string SessionId, string Voter);
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
using System.Text.Json;
2+
using KeyLoad.Storage.IO;
3+
4+
namespace KeyLoad.AppHost.Features.ClusterRouting;
5+
6+
/// <summary>Validates the exact private Owner record with bounded native JSON parsing.</summary>
7+
internal static class RequestCqrsProbeOwnerReader
8+
{
9+
internal const int MaximumOwnerBytes = 8_192;
10+
internal const UnixFileMode PrivateFileMode = UnixFileMode.UserRead | UnixFileMode.UserWrite;
11+
private const int MaximumJsonDepth = 1;
12+
private const int RequiredFieldCount = 4;
13+
private const int OwnerVersion = 1;
14+
private const string VersionField = "Version";
15+
private const string KindField = "Kind";
16+
private const string SessionField = "SessionId";
17+
private const string VoterField = "Voter";
18+
private const string OwnerKind = "Owner";
19+
private const string InvalidConfiguration = "RequestCqrsProbeConfigurationInvalid";
20+
21+
internal static RequestCqrsProbeOwnerRecord ReadFile(string path)
22+
{
23+
if (OperatingSystem.IsWindows())
24+
{ throw new InvalidOperationException(InvalidConfiguration); }
25+
var identity = OfflineRegularFile.Inspect(path);
26+
if (identity.Length is <= 0 or > MaximumOwnerBytes
27+
|| File.GetUnixFileMode(path) != PrivateFileMode)
28+
{ throw new InvalidOperationException(InvalidConfiguration); }
29+
using var input = OfflineRegularFile.OpenWithIdentity(path, identity, FileAccess.Read,
30+
FileShare.Read, bufferSize: 4_096);
31+
if (input.Length != identity.Length || File.GetUnixFileMode(path) != PrivateFileMode)
32+
{ throw new InvalidOperationException(InvalidConfiguration); }
33+
var bytes = new byte[checked((int)identity.Length)];
34+
input.ReadExactly(bytes);
35+
if (input.ReadByte() != -1 || input.Length != identity.Length
36+
|| OfflineRegularFile.Inspect(path) != identity)
37+
{ throw new InvalidOperationException(InvalidConfiguration); }
38+
return Read(bytes);
39+
}
40+
41+
internal static RequestCqrsProbeOwnerRecord Read(byte[] bytes)
42+
{
43+
if (bytes.Length is <= 0 or > MaximumOwnerBytes)
44+
{ throw new InvalidOperationException(InvalidConfiguration); }
45+
using var document = JsonDocument.Parse(bytes, new JsonDocumentOptions { MaxDepth = MaximumJsonDepth });
46+
if (document.RootElement.ValueKind != JsonValueKind.Object)
47+
{ throw new InvalidOperationException(InvalidConfiguration); }
48+
var seen = new HashSet<string>(StringComparer.Ordinal);
49+
var version = 0;
50+
string? kind = null;
51+
string? session = null;
52+
string? ownerVoter = null;
53+
foreach (var field in document.RootElement.EnumerateObject())
54+
{
55+
if (!seen.Add(field.Name))
56+
{ throw new InvalidOperationException(InvalidConfiguration); }
57+
ReadField(field, ref version, ref kind, ref session, ref ownerVoter);
58+
}
59+
if (seen.Count != RequiredFieldCount || version != OwnerVersion || kind != OwnerKind
60+
|| session is null || ownerVoter is null)
61+
{ throw new InvalidOperationException(InvalidConfiguration); }
62+
return new(version, kind, session, ownerVoter);
63+
}
64+
65+
private static void ReadField(JsonProperty field, ref int version, ref string? kind,
66+
ref string? session, ref string? voter)
67+
{
68+
switch (field.Name)
69+
{
70+
case VersionField when field.Value.ValueKind == JsonValueKind.Number && field.Value.TryGetInt32(out var value):
71+
version = value;
72+
break;
73+
case KindField:
74+
kind = ReadString(field.Value);
75+
break;
76+
case SessionField:
77+
session = ReadString(field.Value);
78+
break;
79+
case VoterField:
80+
voter = ReadString(field.Value);
81+
break;
82+
default:
83+
throw new InvalidOperationException(InvalidConfiguration);
84+
}
85+
}
86+
87+
private static string ReadString(JsonElement value)
88+
{
89+
if (value.ValueKind != JsonValueKind.String)
90+
{ throw new InvalidOperationException(InvalidConfiguration); }
91+
return value.GetString() is { Length: > 0 } text
92+
? text : throw new InvalidOperationException(InvalidConfiguration);
93+
}
94+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
using KeyLoad.AppHost.Features.ClusterReplication;
2+
using Microsoft.Extensions.Configuration;
3+
4+
namespace KeyLoad.AppHost.Features.ClusterRouting;
5+
6+
/// <summary>Admits the private per-voter phase-control mount only for an ephemeral RF3 host.</summary>
7+
internal sealed class RequestCqrsProbeProfile
8+
{
9+
internal const string Section = "KeyLoadTests:RequestCqrsProbe";
10+
private const string MountRoot = "/request-probes";
11+
private const string EnabledEnvironment = "KeyLoad__RequestCqrsProbe__Enabled";
12+
private const string RootEnvironment = "KeyLoad__RequestCqrsProbe__Root";
13+
private const string SessionEnvironment = "KeyLoad__RequestCqrsProbe__SessionId";
14+
private const string EnabledValue = "true";
15+
private const string InvalidConfiguration = "RequestCqrsProbeConfigurationInvalid";
16+
17+
private readonly IReadOnlyDictionary<string, string> nodeRoots;
18+
19+
private RequestCqrsProbeProfile(string sessionId, IReadOnlyDictionary<string, string> nodeRoots)
20+
{ SessionId = sessionId; this.nodeRoots = nodeRoots; }
21+
22+
internal string SessionId { get; }
23+
24+
internal static void ValidateMode(IConfiguration configuration)
25+
{ _ = RequestCqrsProbeProfileSettingsReader.Read(configuration); }
26+
27+
internal static RequestCqrsProbeProfile? Read(IDistributedApplicationBuilder builder, string dataRoot,
28+
bool ephemeral, int? benchmarkNodeCount, IReadOnlyDictionary<string, RuntimeContainerImage> images)
29+
{
30+
ArgumentNullException.ThrowIfNull(builder);
31+
var settings = RequestCqrsProbeProfileSettingsReader.Read(builder.Configuration);
32+
if (settings is null)
33+
{ return null; }
34+
if (!ephemeral || benchmarkNodeCount is not null)
35+
{ throw new InvalidOperationException(InvalidConfiguration); }
36+
ArgumentException.ThrowIfNullOrWhiteSpace(dataRoot);
37+
ArgumentNullException.ThrowIfNull(images);
38+
ValidateImages(images);
39+
var nodeRoots = RequestCqrsProbeProfilePaths.Validate(settings.Root, settings.SessionId, dataRoot);
40+
return new(settings.SessionId, nodeRoots);
41+
}
42+
43+
internal void Apply(IResourceBuilder<ContainerResource> resource, string node)
44+
{
45+
ArgumentNullException.ThrowIfNull(resource);
46+
if (!nodeRoots.TryGetValue(node, out var nodeRoot))
47+
{ throw new InvalidOperationException(InvalidConfiguration); }
48+
resource.WithBindMount(nodeRoot, MountRoot)
49+
.WithEnvironment(EnabledEnvironment, EnabledValue)
50+
.WithEnvironment(RootEnvironment, MountRoot)
51+
.WithEnvironment(SessionEnvironment, SessionId);
52+
}
53+
54+
private static void ValidateImages(IReadOnlyDictionary<string, RuntimeContainerImage> images)
55+
{
56+
if (images.Count != RequestCqrsProbeProfileSettingsReader.VoterNames.Count
57+
|| RequestCqrsProbeProfileSettingsReader.VoterNames.Any(node => !images.TryGetValue(node, out var image) || image is null))
58+
{ throw new InvalidOperationException(InvalidConfiguration); }
59+
var voters = RequestCqrsProbeProfileSettingsReader.VoterNames;
60+
var reference = images[voters[0]].Reference;
61+
if (string.IsNullOrWhiteSpace(reference)
62+
|| !string.Equals(images[voters[1]].Reference, reference, StringComparison.Ordinal)
63+
|| !string.Equals(images[voters[2]].Reference, reference, StringComparison.Ordinal))
64+
{ throw new InvalidOperationException(InvalidConfiguration); }
65+
}
66+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
using System.Text.Json;
2+
3+
namespace KeyLoad.AppHost.Features.ClusterRouting;
4+
5+
/// <summary>Validates pre-created private control paths without creating, changing or deleting them.</summary>
6+
internal static class RequestCqrsProbeProfilePaths
7+
{
8+
private const string OwnerFileName = "owner.json";
9+
private const string InvalidConfiguration = "RequestCqrsProbeConfigurationInvalid";
10+
private const int MaximumPathCharacters = 4_096;
11+
private const UnixFileMode PrivateDirectoryMode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute;
12+
13+
internal static IReadOnlyDictionary<string, string> Validate(string configuredRoot, string sessionId,
14+
string dataRoot)
15+
{
16+
try
17+
{
18+
ValidatePlatform();
19+
var root = CanonicalDirectory(configuredRoot);
20+
var data = CanonicalDirectory(dataRoot);
21+
if (IsWithin(root, data) || IsWithin(data, root))
22+
{ throw new InvalidOperationException(InvalidConfiguration); }
23+
ValidateDirectoryAndAncestors(root, PrivateDirectoryMode);
24+
ValidateRootEntries(root);
25+
var nodes = new Dictionary<string, string>(StringComparer.Ordinal);
26+
foreach (var voter in RequestCqrsProbeProfileSettingsReader.VoterNames)
27+
{
28+
var directory = Path.Combine(root, voter);
29+
ValidateDirectoryAndAncestors(directory, PrivateDirectoryMode);
30+
ValidateOwnerDirectory(directory, sessionId, global::ClusterResources.Origin(voter));
31+
nodes.Add(voter, directory);
32+
}
33+
return nodes;
34+
}
35+
catch (Exception error) when (IsPathOrFileFailure(error))
36+
{ throw new InvalidOperationException(InvalidConfiguration); }
37+
}
38+
39+
private static string CanonicalDirectory(string path)
40+
{
41+
if (path.Length is 0 or > MaximumPathCharacters || !Path.IsPathFullyQualified(path))
42+
{ throw new InvalidOperationException(InvalidConfiguration); }
43+
var full = Path.GetFullPath(path);
44+
var trimmed = Path.TrimEndingDirectorySeparator(full);
45+
if (!string.Equals(full, path, StringComparison.Ordinal)
46+
|| !string.Equals(trimmed, full, StringComparison.Ordinal)
47+
|| string.Equals(trimmed, Path.GetPathRoot(full), StringComparison.Ordinal))
48+
{ throw new InvalidOperationException(InvalidConfiguration); }
49+
return full;
50+
}
51+
52+
private static void ValidatePlatform()
53+
{
54+
if (OperatingSystem.IsWindows())
55+
{ throw new PlatformNotSupportedException(InvalidConfiguration); }
56+
}
57+
58+
private static void ValidateDirectoryAndAncestors(string path, UnixFileMode? expectedMode)
59+
{
60+
if (OperatingSystem.IsWindows())
61+
{ throw new InvalidOperationException(InvalidConfiguration); }
62+
for (var current = path; current is not null; current = Path.GetDirectoryName(current))
63+
{
64+
var attributes = File.GetAttributes(current);
65+
if ((attributes & FileAttributes.Directory) == 0 || (attributes & FileAttributes.ReparsePoint) != 0)
66+
{ throw new InvalidOperationException(InvalidConfiguration); }
67+
if (string.Equals(current, path, StringComparison.Ordinal) && expectedMode is { } mode
68+
&& File.GetUnixFileMode(current) != mode)
69+
{ throw new InvalidOperationException(InvalidConfiguration); }
70+
}
71+
}
72+
73+
private static void ValidateRootEntries(string root)
74+
{
75+
var voters = RequestCqrsProbeProfileSettingsReader.VoterNames;
76+
var entries = Directory.EnumerateFileSystemEntries(root).Take(voters.Count + 1).ToArray();
77+
if (entries.Length != voters.Count
78+
|| voters.Any(voter => !entries.Contains(Path.Combine(root, voter), StringComparer.Ordinal)))
79+
{ throw new InvalidOperationException(InvalidConfiguration); }
80+
}
81+
82+
private static void ValidateOwnerDirectory(string directory, string sessionId, string voter)
83+
{
84+
var entries = Directory.EnumerateFileSystemEntries(directory).Take(2).ToArray();
85+
if (entries.Length != 1 || !string.Equals(Path.GetFileName(entries[0]), OwnerFileName, StringComparison.Ordinal))
86+
{ throw new InvalidOperationException(InvalidConfiguration); }
87+
var ownerPath = Path.Combine(directory, OwnerFileName);
88+
var owner = RequestCqrsProbeOwnerReader.ReadFile(ownerPath);
89+
if (!string.Equals(owner.SessionId, sessionId, StringComparison.Ordinal)
90+
|| !string.Equals(owner.Voter, voter, StringComparison.Ordinal))
91+
{ throw new InvalidOperationException(InvalidConfiguration); }
92+
}
93+
94+
private static bool IsWithin(string candidate, string root)
95+
{
96+
var relative = Path.GetRelativePath(root, candidate);
97+
return relative == "." || (!Path.IsPathRooted(relative) && relative != ".."
98+
&& !relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal)
99+
&& !relative.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal));
100+
}
101+
102+
private static bool IsPathOrFileFailure(Exception error)
103+
=> error is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException
104+
or System.Security.SecurityException or JsonException or global::KeyLoad.KeyLoadException;
105+
}

0 commit comments

Comments
 (0)