R-O: CultNetSelectionCursorKey is a per-process (per-CultNetDatabase-instance) 32-byte
random key. CultNetSelectionCursor.ComputeDigest is now an HMAC-SHA256 under that key
instead of an unkeyed SHA-256, so a cursor cannot be forged without the key and does not
verify across a restart (a fresh CultNetDatabase mints a fresh key). The cursor body
(asOf/ordinal/schemaId/recordKey/digest) is length-prefixed (<byte length>:<value>, no
delimiter) instead of joined with a bare "\x01"-separated string, so a record key
carrying any character, U+241F included, round-trips. CultNetSelectionEvaluator.Select/
Page/ComputeDigest take an optional CultNetSelectionCursorKey, defaulting to a
process-wide key generated once so existing callers that never wire up a database still
get a keyed digest. CultNetDatabase gets a CursorKey property (random unless
CultNetDatabaseOptions.CursorKey is supplied) and passes it through
CultNetDatabaseServer.CreateSelectionResponse.
Rust already landed R-O (5aac120) under its own algorithm, deliberately not byte-matching
the reference: its own doc comment says a cursor is never handed across runtimes, so only
collision-freedom and keying are load-bearing, not the wire bytes. That is a real
divergence from this pass's instruction to match Rust's wire encoding exactly - reported
in full in the batch report.
R-Q: the two replica-apply paths (ApplyCommittedPutAsync, ApplyCommittedDelete) recorded
the mutation-log entry but never set _lastWriteSequence, so a row's order/cursor position
looked like "never written" until it was written again locally. Both now set it from the
entry's own sequence, matching AppendMutationLogEntry's local-commit path.
InitializeLogSequencesFromStore rebuilt only _nextLogSequences at startup; it now also
rebuilds _lastWriteSequence by replaying the durable log's Put/Delete entries in sequence
order. CultNetDatabase.ResolveShard is a new public wrapper over the existing shard
resolution; CultNetDocumentRegistry.SelectPage/CreateSelectionResponse take an optional
shardIdOf callback and refuse (reusing CultNetSelectionInvalidException/selection_invalid,
R-N) when a selection's matched rows resolve to more than one shard's log, because asOf is
a single watermark and is not exact for a shard-spanning page.
CultNetDatabaseServer.CreateSelectionResponse wires shardIdOf/cursorKey through from the
database.
Also fixes a defect the Rust Hands found while matching wire bytes:
CultNetErrorMessage.ForReferenceOutsideTarget double-prefixed "reference_outside_target: "
- the exception's own message already carries it. Now prefixed once, matching Rust's
ForReferenceOutsideTarget.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
308 lines
18 KiB
C#
308 lines
18 KiB
C#
#nullable enable
|
|
using System;
|
|
using System.Linq;
|
|
using GameCult.Caching;
|
|
using NUnit.Framework;
|
|
|
|
namespace GameCult.Networking.Tests
|
|
{
|
|
// CultNet typed selection, Cut 1, second Hands pass on the fix batch (docs/cultnet-selection-cut.md,
|
|
// "Self's rulings for the Cut 1 fix batch" and "The C# half of the fix batch landed"): regression
|
|
// tests for the rules the first Hands pass fixed but left proven only by 7903853..3d32c67's own
|
|
// (unnamed, un-mutated) coverage or, for R-E's cites.target.schemaId door refusal, left entirely
|
|
// unimplemented. Independent of any live server/client transport, same style as
|
|
// CultNetSelectionEvaluatorTests - a hand-built row set against CultNetSelectionEvaluator directly.
|
|
// Reuses CultNetSelectionEvaluatorTests' public fixture types (SelLeafA/SelLeafB/SelFixtureMiddle/
|
|
// SelCiter/SelCiterNarrow) rather than declaring a fourth near-identical set.
|
|
public sealed class CultNetSelectionFixBatchTests
|
|
{
|
|
private static CultDocumentRegistry Registry() => CultDocumentRegistry.ForTypes(new[]
|
|
{
|
|
typeof(CultNetSelectionEvaluatorTests.SelLeafA),
|
|
typeof(CultNetSelectionEvaluatorTests.SelLeafB),
|
|
typeof(CultNetSelectionEvaluatorTests.SelCiter)
|
|
});
|
|
|
|
private static CultNetSelectionEvaluator.Row Row(CultDocumentRegistry registry, object document, string key, long ordinal) =>
|
|
new(registry.GetRequired(document.GetType()), new CultRecordKey(key), document, ordinal);
|
|
|
|
// R-B: edges are anchored to the row the hop direction actually reports against - the cited row
|
|
// under `cited`, not the citer - and edge order is deterministic: page-row order, then
|
|
// (from, role, to) in code-point order. Two citers citing the same target through the same role
|
|
// put two edges on one page row (`target`), so only a real (from, role, to) tie-break - not
|
|
// insertion order - can produce "citer-a" before "citer-b".
|
|
[Test]
|
|
public void Evaluator_AnchorsCitedEdgesToTheCitedRowAndOrdersThemByFromThenRole()
|
|
{
|
|
var registry = Registry();
|
|
var target = new CultNetSelectionEvaluatorTests.SelLeafA { Name = "target", Kind = "k", Mass = 1 };
|
|
var citerB = new CultNetSelectionEvaluatorTests.SelCiter { Name = "b", Design = new CultRecordRef<CultNetSelectionEvaluatorTests.SelFixtureMiddle>(new CultRecordKey("target")) };
|
|
var citerA = new CultNetSelectionEvaluatorTests.SelCiter { Name = "a", Design = new CultRecordRef<CultNetSelectionEvaluatorTests.SelFixtureMiddle>(new CultRecordKey("target")) };
|
|
// Supplied out of code-point order (b before a) on purpose - the assertion only passes if
|
|
// EdgesFor's own tie-break sorts them, not if it merely preserves row-supply order.
|
|
var rows = new[]
|
|
{
|
|
Row(registry, target, "target", 1),
|
|
Row(registry, citerB, "citer-b", 2),
|
|
Row(registry, citerA, "citer-a", 3)
|
|
};
|
|
|
|
var selection = new CultNetSelection
|
|
{
|
|
Schemas = new[] { registry.GetRequired<CultNetSelectionEvaluatorTests.SelLeafA>().SchemaId },
|
|
Cited = new CultNetIncoming { Role = "Design", Exists = true }
|
|
};
|
|
var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1);
|
|
|
|
Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "target" }));
|
|
// Anchor: both edges must be reported (dropped entirely if wrongly anchored to the citer,
|
|
// which is not on this page under `cited`).
|
|
Assert.That(evaluation.Edges.Select(e => e.From.Key.Value), Is.EqualTo(new[] { "citer-a", "citer-b" }));
|
|
Assert.That(evaluation.Edges.All(e => e.To.Key.Value == "target"), Is.True);
|
|
}
|
|
|
|
// R-C: every ordered comparison the vocabulary makes uses Unicode code-point order, not UTF-16
|
|
// code-unit order. U+E000 (BMP private use) sorts before U+1F602 (astral, "😂") in code-point
|
|
// order despite the astral character's leading UTF-16 surrogate (0xD83D) being numerically
|
|
// smaller than 0xE000 - a code-unit ("ordinal") compare sorts it the other way.
|
|
private const string BmpKey = "-row";
|
|
private const string AstralKey = "😂-row";
|
|
|
|
[Test]
|
|
public void Evaluator_OrdersAstralKeysByCodePointNotUtf16CodeUnit()
|
|
{
|
|
var registry = Registry();
|
|
var rows = new[]
|
|
{
|
|
Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "astral", Kind = "k", Mass = 1 }, AstralKey, 1),
|
|
Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "bmp", Kind = "k", Mass = 1 }, BmpKey, 1)
|
|
};
|
|
|
|
var evaluation = CultNetSelectionEvaluator.Select(registry, rows, new CultNetSelection(), asOf: 1);
|
|
|
|
Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { BmpKey, AstralKey }));
|
|
}
|
|
|
|
// R-C, the cursor half: the cursor's position search must compare by the same code-point order
|
|
// the row tiebreak uses, or a page walk over astral/BMP-boundary keys can skip or repeat rows.
|
|
[Test]
|
|
public void Evaluator_CursorPositionUsesCodePointOrderMatchingTheRowTiebreak()
|
|
{
|
|
var registry = Registry();
|
|
var rows = new[]
|
|
{
|
|
Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "astral", Kind = "k", Mass = 1 }, AstralKey, 1),
|
|
Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "bmp", Kind = "k", Mass = 1 }, BmpKey, 1)
|
|
};
|
|
var selection = new CultNetSelection { Limit = 1 };
|
|
|
|
var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1);
|
|
Assert.That(first.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { BmpKey }));
|
|
Assert.That(first.NextCursor, Is.Not.Null);
|
|
|
|
selection.Cursor = first.NextCursor;
|
|
var second = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1);
|
|
Assert.That(second.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { AstralKey }));
|
|
}
|
|
|
|
// R-E: cites.target.schemaId is matched through the same alias matcher `schemas` uses (a
|
|
// SchemaName, not only the real content-hash SchemaId), and a target whose schema matches
|
|
// nothing declared is refused at the door - never answered with a quietly empty page.
|
|
[Test]
|
|
public void Evaluator_MatchesCitesTargetThroughTheAliasMatcher()
|
|
{
|
|
var registry = Registry();
|
|
var target = new CultNetSelectionEvaluatorTests.SelLeafA { Name = "target", Kind = "k", Mass = 1 };
|
|
var citer = new CultNetSelectionEvaluatorTests.SelCiter { Name = "citer", Design = new CultRecordRef<CultNetSelectionEvaluatorTests.SelFixtureMiddle>(new CultRecordKey("target")) };
|
|
var rows = new[] { Row(registry, target, "target", 1), Row(registry, citer, "citer", 2) };
|
|
|
|
var alias = registry.GetRequired<CultNetSelectionEvaluatorTests.SelLeafA>().SchemaName;
|
|
var selection = new CultNetSelection
|
|
{
|
|
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = alias, RecordKey = "target" } }
|
|
};
|
|
var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1);
|
|
|
|
Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "citer" }));
|
|
}
|
|
|
|
[Test]
|
|
public void Validation_RefusesACitesTargetSchemaThatMatchesNoDeclaredSchema()
|
|
{
|
|
var descriptors = Registry().AllDescriptors.ToArray();
|
|
var selection = new CultNetSelection
|
|
{
|
|
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = "no-such-schema", RecordKey = "target" } }
|
|
};
|
|
|
|
var ex = Assert.Throws<CultNetSelectionInvalidException>(() => selection.Validate(descriptors));
|
|
Assert.That(ex!.Field, Is.EqualTo("cites.target.schemaId"));
|
|
}
|
|
|
|
// R-E: cites.target.schemaId is resolved against every declared schema, not only the schemas
|
|
// `selection.Schemas` itself reaches - a cites target is a separate structural reference, not
|
|
// filtered by the selection's own schema allowlist. A plausible weaker implementation reuses the
|
|
// `reachable` set already computed for Fields validation and wrongly refuses a valid target whose
|
|
// schema sits outside it.
|
|
[Test]
|
|
public void Validation_ResolvesACitesTargetSchemaEvenWhenNotInTheSelectionsOwnSchemasFilter()
|
|
{
|
|
var registry = Registry();
|
|
var descriptors = registry.AllDescriptors.ToArray();
|
|
var citerSchemaId = registry.GetRequired<CultNetSelectionEvaluatorTests.SelCiter>().SchemaId;
|
|
var leafAlias = registry.GetRequired<CultNetSelectionEvaluatorTests.SelLeafA>().SchemaName;
|
|
|
|
var selection = new CultNetSelection
|
|
{
|
|
Schemas = new[] { citerSchemaId },
|
|
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = leafAlias, RecordKey = "target" } }
|
|
};
|
|
|
|
Assert.DoesNotThrow(() => selection.Validate(descriptors));
|
|
}
|
|
|
|
// R-F: the door is inside Select/EvaluateAll unconditionally - there is no public entry point
|
|
// that evaluates a selection without validating it first.
|
|
[Test]
|
|
public void Evaluator_SelectValidatesFirstAndNeverAnswersAnInvalidSelection()
|
|
{
|
|
var registry = Registry();
|
|
var rows = new[] { Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "a", Kind = "k", Mass = 1 }, "a", 1) };
|
|
var invalid = new CultNetSelection { Schemas = Array.Empty<string>() };
|
|
|
|
Assert.Throws<CultNetSelectionInvalidException>(() => CultNetSelectionEvaluator.Select(registry, rows, invalid, asOf: 1));
|
|
}
|
|
|
|
// R-H: the cursor digest length-prefixes every string and list, so no delimiter choice can make
|
|
// two different selections collide. The pre-fix scheme joined keys with "," and a field
|
|
// predicate's values with "|" with no length prefix - Keys=["a,b"] (one string containing a
|
|
// literal comma) digested the same as Keys=["a","b"] (two strings), and a field's
|
|
// Values=["a|b"] digested the same as Values=["a","b"].
|
|
[Test]
|
|
public void ComputeDigest_DoesNotCollideOnDelimiterAmbiguousKeysOrFieldValues()
|
|
{
|
|
var keysOne = new CultNetSelection { Keys = new[] { "a,b" } };
|
|
var keysTwo = new CultNetSelection { Keys = new[] { "a", "b" } };
|
|
Assert.That(CultNetSelectionCursor.ComputeDigest(keysOne), Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(keysTwo)));
|
|
|
|
var valuesOne = new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "a|b" } } } };
|
|
var valuesTwo = new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "a", "b" } } } };
|
|
Assert.That(CultNetSelectionCursor.ComputeDigest(valuesOne), Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(valuesTwo)));
|
|
}
|
|
|
|
// S5-DigestNoFields (fix batch 3 "Surviving mutants"): the digest reads selection.Fields - a
|
|
// mutant that drops the fields loop would digest two selections identically as long as their
|
|
// schemas/keys/hop/projection agree, letting a cursor minted for one selection page a
|
|
// differently-fielded one.
|
|
[Test]
|
|
public void ComputeDigest_DiffersWhenOnlyFieldsDiffer()
|
|
{
|
|
var withFieldA = new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "a" } } } };
|
|
var withFieldB = new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "b" } } } };
|
|
var noFields = new CultNetSelection();
|
|
|
|
Assert.That(CultNetSelectionCursor.ComputeDigest(withFieldA), Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(withFieldB)));
|
|
Assert.That(CultNetSelectionCursor.ComputeDigest(withFieldA), Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(noFields)));
|
|
}
|
|
|
|
// R-O (fix batch 3, F6 "cursors are keyed"): a cursor's digest is an HMAC under the answering
|
|
// process's key - a page request carrying a cursor minted under a different key (forged, or
|
|
// minted by a different process) is refused cursor_invalid, never answered.
|
|
[Test]
|
|
public void Page_RefusesACursorMintedUnderADifferentKey()
|
|
{
|
|
var registry = Registry();
|
|
var rows = Enumerable.Range(0, 3)
|
|
.Select(i => Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = $"n{i}", Kind = "k", Mass = i }, $"k{i}", i))
|
|
.ToArray();
|
|
var selection = new CultNetSelection { Limit = 1 };
|
|
var mintingKey = CultNetSelectionCursorKey.Random();
|
|
var otherKey = CultNetSelectionCursorKey.Random();
|
|
|
|
var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: mintingKey);
|
|
Assert.That(first.NextCursor, Is.Not.Null);
|
|
|
|
selection.Cursor = first.NextCursor;
|
|
var ex = Assert.Throws<CultNetSelectionCursorException>(
|
|
() => CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: otherKey));
|
|
Assert.That(ex!.Code, Is.EqualTo("cursor_invalid"));
|
|
}
|
|
|
|
// R-O: a cursor minted before a (simulated) restart does not verify against the restarted
|
|
// process's fresh key - the same refusal as a forged cursor, because from the answering
|
|
// process's point of view they are indistinguishable (F6: "not surviving a restart is the
|
|
// accepted cost, not a bug").
|
|
[Test]
|
|
public void Page_RefusesACursorMintedBeforeARestart()
|
|
{
|
|
var registry = Registry();
|
|
var rows = Enumerable.Range(0, 3)
|
|
.Select(i => Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = $"n{i}", Kind = "k", Mass = i }, $"k{i}", i))
|
|
.ToArray();
|
|
var selection = new CultNetSelection { Limit = 1 };
|
|
var preRestartKey = CultNetSelectionCursorKey.Random();
|
|
|
|
var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: preRestartKey);
|
|
Assert.That(first.NextCursor, Is.Not.Null);
|
|
|
|
// A restart is a fresh process key, exactly as CultNetDatabase's constructor mints a fresh
|
|
// CursorKey per instance unless one is supplied.
|
|
var postRestartKey = CultNetSelectionCursorKey.Random();
|
|
selection.Cursor = first.NextCursor;
|
|
var ex = Assert.Throws<CultNetSelectionCursorException>(
|
|
() => CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: postRestartKey));
|
|
Assert.That(ex!.Code, Is.EqualTo("cursor_invalid"));
|
|
}
|
|
|
|
// R-O: the cursor body is length-prefixed, not delimiter-joined, so a record key carrying any
|
|
// character at all - including U+241F, which broke the pre-fix delimiter-split parse - round-trips.
|
|
[Test]
|
|
public void Cursor_RoundTripsARecordKeyContainingU241F()
|
|
{
|
|
var registry = Registry();
|
|
var weirdKey = "row-␟-two";
|
|
var rows = new[]
|
|
{
|
|
Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "a", Kind = "k", Mass = 0 }, "a-first", 0),
|
|
Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "b", Kind = "k", Mass = 1 }, weirdKey, 1)
|
|
};
|
|
var selection = new CultNetSelection { Limit = 1 };
|
|
var key = CultNetSelectionCursorKey.Random();
|
|
|
|
var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: key);
|
|
Assert.That(first.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "a-first" }));
|
|
Assert.That(first.NextCursor, Is.Not.Null);
|
|
|
|
selection.Cursor = first.NextCursor;
|
|
var second = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: key);
|
|
Assert.That(second.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { weirdKey }));
|
|
Assert.That(second.NextCursor, Is.Null);
|
|
}
|
|
|
|
// R-J: any_of on a numeric alias compares TryGetIndexNumber's canonical rendering, not the
|
|
// string getter's culture-dependent ToString() - a float large enough that .NET's default
|
|
// ToString() renders exponent notation ("1E+21") must still match its canonical decimal form.
|
|
[Test]
|
|
public void Evaluator_AnyOfOnANumericAliasComparesTheCanonicalRenderingNotToString()
|
|
{
|
|
var registry = Registry();
|
|
var row = new CultNetSelectionEvaluatorTests.SelLeafA { Name = "big", Kind = "k", Mass = 1e21f };
|
|
var rows = new[] { Row(registry, row, "big", 1) };
|
|
var descriptor = registry.GetRequired<CultNetSelectionEvaluatorTests.SelLeafA>();
|
|
Assert.That(descriptor.TryGetIndexNumber(row, "mass", out var canonical), Is.True);
|
|
// .NET's default ToString() on this magnitude renders exponent notation ("1E+21"); the
|
|
// canonical decimal never does - if the two happened to be textually equal this assertion,
|
|
// not the mutant, would be the problem, so pin that precondition rather than assume it.
|
|
Assert.That(canonical, Does.Not.Contain("E"));
|
|
Assert.That(row.Mass.ToString(System.Globalization.CultureInfo.InvariantCulture), Is.Not.EqualTo(canonical));
|
|
|
|
var selection = new CultNetSelection
|
|
{
|
|
Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = "any_of", Values = new[] { canonical! } } }
|
|
};
|
|
var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1);
|
|
|
|
Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "big" }));
|
|
}
|
|
}
|
|
}
|