Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 1 addition & 2 deletions docs/guide/vector-clock.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
8 changes: 8 additions & 0 deletions property-tests/VectorClockProperties.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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
[<Property>]
let ``Zero counter entries canonicalize to absence`` (nodeId: uint16) =
let clock = VectorClock.Parse(string nodeId + ":0")
clock = VectorClock()
&& clock.Get(nodeId) = 0UL
&& clock.ToString() = ""
13 changes: 11 additions & 2 deletions src/Distributed/VectorClock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,8 @@ public int GetBinarySize()

/// <summary>
/// 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.
/// </summary>
public static VectorClock ReadFrom(ReadOnlySpan<byte> source)
{
Expand All @@ -373,13 +374,15 @@ public static VectorClock ReadFrom(ReadOnlySpan<byte> source)

var nodeIds = new ushort[countValue];
var counters = new ulong[countValue];
var hasZeroCounter = false;

var offset = 4;
for (var i = 0; i < countValue; i++)
{
nodeIds[i] = BinaryPrimitives.ReadUInt16BigEndian(source.Slice(offset, 2));
offset += 2;
counters[i] = BinaryPrimitives.ReadUInt64BigEndian(source.Slice(offset, 8));
hasZeroCounter |= counters[i] == 0;
offset += 8;
}

Expand All @@ -393,7 +396,7 @@ public static VectorClock ReadFrom(ReadOnlySpan<byte> source)
}
}

if (isSortedUnique)
if (isSortedUnique && !hasZeroCounter)
return new VectorClock(nodeIds, counters);

var pairs = new List<(ushort nodeId, ulong counter)>(nodeIds.Length);
Expand Down Expand Up @@ -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<ushort, ulong>(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;
Expand Down
3 changes: 3 additions & 0 deletions src/Distributed/VectorClockBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ public VectorClock ToSnapshot()

private void MergeEntry(ushort nodeId, ulong counter)
{
if (counter == 0)
return;

if (_count == 0)
{
EnsureCapacity(1);
Expand Down
49 changes: 49 additions & 0 deletions tests/VectorClockTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -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()
{
Expand Down
Loading