diff --git a/CHANGELOG.md b/CHANGELOG.md index d14e0ad..4bb344a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 ## [Unreleased] +### Fixed +- Canonicalize explicit zero-counter `VectorClock` entries away during parse/read paths so structural equality matches vector-clock semantics for missing entries. + ### Added - Add opt-in `UuidV7FactoryStatistics` counters for generated UUIDs, clock rollback, counter overflow, spin-wait, logical drift, CAS retries, and random-buffer refills. - Add opt-in UUIDv7 node partitioning that reserves 1 to 16 `rand_b` bits for a node, shard, process, or deployment discriminator. diff --git a/docs/changelog.md b/docs/changelog.md index a4fe7dd..8b288c7 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -10,6 +10,9 @@ This page mirrors the repository root `CHANGELOG.md`. ## [Unreleased] +### Fixed +- Canonicalize explicit zero-counter `VectorClock` entries away during parse/read paths so structural equality matches vector-clock semantics for missing entries. + ### Added - Add opt-in `UuidV7FactoryStatistics` counters for generated UUIDs, clock rollback, counter overflow, spin-wait, logical drift, CAS retries, and random-buffer refills. - Add opt-in UUIDv7 node partitioning that reserves 1 to 16 `rand_b` bits for a node, shard, process, or deployment discriminator. diff --git a/docs/guide/vector-clock.md b/docs/guide/vector-clock.md index a675e2f..66579d0 100644 --- a/docs/guide/vector-clock.md +++ b/docs/guide/vector-clock.md @@ -108,5 +108,4 @@ Binary format: `[count:u32 big-endian][(nodeId:u16 big-endian, counter:u64 big-endian)]*` -`ReadFrom(...)` also canonicalizes unsorted or duplicate node IDs by taking the maximum counter per node. - +`ReadFrom(...)` also canonicalizes unsorted or duplicate node IDs by taking the maximum counter per node. Explicit zero-counter entries are canonicalized away, because a missing vector-clock entry is semantically the same as counter `0`. diff --git a/property-tests/VectorClockProperties.fs b/property-tests/VectorClockProperties.fs index 05fec15..274717c 100644 --- a/property-tests/VectorClockProperties.fs +++ b/property-tests/VectorClockProperties.fs @@ -94,3 +94,11 @@ let ``Merge is monotone with respect to inputs`` (a: VectorClock) (b: VectorCloc let ``Increment is stable under merge with original`` (clock: VectorClock) (nodeId: uint16) = let inc = clock.Increment(nodeId) clock.Merge(inc) = inc && inc.Merge(clock) = inc + +/// Property: explicit zero counters are the same as absent entries in canonical form +[] +let ``Zero counter entries canonicalize to absence`` (nodeId: uint16) = + let clock = VectorClock.Parse(string nodeId + ":0") + clock = VectorClock() + && clock.Get(nodeId) = 0UL + && clock.ToString() = "" diff --git a/src/Distributed/VectorClock.cs b/src/Distributed/VectorClock.cs index 26f2328..aaffd22 100644 --- a/src/Distributed/VectorClock.cs +++ b/src/Distributed/VectorClock.cs @@ -351,7 +351,8 @@ public int GetBinarySize() /// /// Reads a vector clock from its binary representation. - /// Automatically deduplicates entries by taking the maximum counter value for duplicate node IDs. + /// Automatically deduplicates entries by taking the maximum counter value for duplicate node IDs and dropping + /// zero-counter entries. /// public static VectorClock ReadFrom(ReadOnlySpan source) { @@ -373,6 +374,7 @@ public static VectorClock ReadFrom(ReadOnlySpan source) var nodeIds = new ushort[countValue]; var counters = new ulong[countValue]; + var hasZeroCounter = false; var offset = 4; for (var i = 0; i < countValue; i++) @@ -380,6 +382,7 @@ public static VectorClock ReadFrom(ReadOnlySpan source) nodeIds[i] = BinaryPrimitives.ReadUInt16BigEndian(source.Slice(offset, 2)); offset += 2; counters[i] = BinaryPrimitives.ReadUInt64BigEndian(source.Slice(offset, 8)); + hasZeroCounter |= counters[i] == 0; offset += 8; } @@ -393,7 +396,7 @@ public static VectorClock ReadFrom(ReadOnlySpan source) } } - if (isSortedUnique) + if (isSortedUnique && !hasZeroCounter) return new VectorClock(nodeIds, counters); var pairs = new List<(ushort nodeId, ulong counter)>(nodeIds.Length); @@ -458,12 +461,18 @@ private static VectorClock CreateCanonical(List<(ushort nodeId, ulong counter)> if (pairs.Count == 1) { var (nodeId, counter) = pairs[0]; + if (counter == 0) + return new VectorClock(); + return new VectorClock([nodeId], [counter]); } var maxByNodeId = new Dictionary(capacity: pairs.Count); foreach (var (nodeId, counter) in pairs) { + if (counter == 0) + continue; + ref var existing = ref CollectionsMarshal.GetValueRefOrAddDefault(maxByNodeId, nodeId, out var exists); if (!exists || counter > existing) existing = counter; diff --git a/src/Distributed/VectorClockBuilder.cs b/src/Distributed/VectorClockBuilder.cs index 8d840d9..95fc696 100644 --- a/src/Distributed/VectorClockBuilder.cs +++ b/src/Distributed/VectorClockBuilder.cs @@ -127,6 +127,9 @@ public VectorClock ToSnapshot() private void MergeEntry(ushort nodeId, ulong counter) { + if (counter == 0) + return; + if (_count == 0) { EnsureCapacity(1); diff --git a/tests/VectorClockTests.cs b/tests/VectorClockTests.cs index ee7fb58..9769006 100644 --- a/tests/VectorClockTests.cs +++ b/tests/VectorClockTests.cs @@ -327,6 +327,28 @@ public void StringSerialization_DuplicateNodes_TakesMaximum() Assert.Equal("1:4,2:3", parsed.ToString()); } + [Fact] + public void StringSerialization_ZeroCounterEntries_AreCanonicalizedAway() + { + var parsed = VectorClock.Parse("11:704,16:0"); + var canonical = VectorClock.Parse("11:704"); + + Assert.Equal(canonical, parsed); + Assert.Equal(VectorClockOrder.Equal, parsed.Compare(canonical)); + Assert.Equal(0UL, parsed.Get(16)); + Assert.Equal("11:704", parsed.ToString()); + } + + [Fact] + public void StringSerialization_ZeroCounterOnly_IsEmpty() + { + var parsed = VectorClock.Parse("16:0"); + + Assert.Equal(new VectorClock(), parsed); + Assert.True(parsed.IsEmpty); + Assert.Equal(string.Empty, parsed.ToString()); + } + [Fact] public void StringSerialization_UsesInvariantCulture() { @@ -395,6 +417,33 @@ public void BinarySerialization_UnsortedInput_Canonicalizes() Assert.Equal("1:7,2:5", parsed.ToString()); } + [Fact] + public void BinarySerialization_ZeroCounterEntries_AreCanonicalizedAway() + { + var buffer = BuildBinary( + (nodeId: 11, counter: 704UL), + (nodeId: 16, counter: 0UL)); + + var parsed = VectorClock.ReadFrom(buffer); + var canonical = VectorClock.Parse("11:704"); + + Assert.Equal(canonical, parsed); + Assert.Equal(14, parsed.GetBinarySize()); + Assert.Equal("11:704", parsed.ToString()); + } + + [Fact] + public void BinarySerialization_ZeroCounterOnly_IsEmpty() + { + var buffer = BuildBinary((nodeId: 16, counter: 0UL)); + + var parsed = VectorClock.ReadFrom(buffer); + + Assert.Equal(new VectorClock(), parsed); + Assert.True(parsed.IsEmpty); + Assert.Equal(4, parsed.GetBinarySize()); + } + [Fact] public void BinarySerialization_CountTooLarge_Throws() {