Skip to content

Commit 831856c

Browse files
authored
Merge pull request #82 from dexcompiler/codex/76-vectorclock-zero-canonicalization
Canonicalize VectorClock zero-counter entries
2 parents f4b7c1c + 5cd2b06 commit 831856c

7 files changed

Lines changed: 78 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2
77

88
## [Unreleased]
99

10+
### Fixed
11+
- Canonicalize explicit zero-counter `VectorClock` entries away during parse/read paths so structural equality matches vector-clock semantics for missing entries.
12+
1013
### Added
1114
- Add opt-in `UuidV7FactoryStatistics` counters for generated UUIDs, clock rollback, counter overflow, spin-wait, logical drift, CAS retries, and random-buffer refills.
1215
- Add opt-in UUIDv7 node partitioning that reserves 1 to 16 `rand_b` bits for a node, shard, process, or deployment discriminator.

docs/changelog.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ This page mirrors the repository root `CHANGELOG.md`.
1010

1111
## [Unreleased]
1212

13+
### Fixed
14+
- Canonicalize explicit zero-counter `VectorClock` entries away during parse/read paths so structural equality matches vector-clock semantics for missing entries.
15+
1316
### Added
1417
- Add opt-in `UuidV7FactoryStatistics` counters for generated UUIDs, clock rollback, counter overflow, spin-wait, logical drift, CAS retries, and random-buffer refills.
1518
- Add opt-in UUIDv7 node partitioning that reserves 1 to 16 `rand_b` bits for a node, shard, process, or deployment discriminator.

docs/guide/vector-clock.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,5 +108,4 @@ Binary format:
108108

109109
`[count:u32 big-endian][(nodeId:u16 big-endian, counter:u64 big-endian)]*`
110110

111-
`ReadFrom(...)` also canonicalizes unsorted or duplicate node IDs by taking the maximum counter per node.
112-
111+
`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`.

property-tests/VectorClockProperties.fs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,11 @@ let ``Merge is monotone with respect to inputs`` (a: VectorClock) (b: VectorCloc
9494
let ``Increment is stable under merge with original`` (clock: VectorClock) (nodeId: uint16) =
9595
let inc = clock.Increment(nodeId)
9696
clock.Merge(inc) = inc && inc.Merge(clock) = inc
97+
98+
/// Property: explicit zero counters are the same as absent entries in canonical form
99+
[<Property>]
100+
let ``Zero counter entries canonicalize to absence`` (nodeId: uint16) =
101+
let clock = VectorClock.Parse(string nodeId + ":0")
102+
clock = VectorClock()
103+
&& clock.Get(nodeId) = 0UL
104+
&& clock.ToString() = ""

src/Distributed/VectorClock.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,8 @@ public int GetBinarySize()
351351

352352
/// <summary>
353353
/// Reads a vector clock from its binary representation.
354-
/// Automatically deduplicates entries by taking the maximum counter value for duplicate node IDs.
354+
/// Automatically deduplicates entries by taking the maximum counter value for duplicate node IDs and dropping
355+
/// zero-counter entries.
355356
/// </summary>
356357
public static VectorClock ReadFrom(ReadOnlySpan<byte> source)
357358
{
@@ -373,13 +374,15 @@ public static VectorClock ReadFrom(ReadOnlySpan<byte> source)
373374

374375
var nodeIds = new ushort[countValue];
375376
var counters = new ulong[countValue];
377+
var hasZeroCounter = false;
376378

377379
var offset = 4;
378380
for (var i = 0; i < countValue; i++)
379381
{
380382
nodeIds[i] = BinaryPrimitives.ReadUInt16BigEndian(source.Slice(offset, 2));
381383
offset += 2;
382384
counters[i] = BinaryPrimitives.ReadUInt64BigEndian(source.Slice(offset, 8));
385+
hasZeroCounter |= counters[i] == 0;
383386
offset += 8;
384387
}
385388

@@ -393,7 +396,7 @@ public static VectorClock ReadFrom(ReadOnlySpan<byte> source)
393396
}
394397
}
395398

396-
if (isSortedUnique)
399+
if (isSortedUnique && !hasZeroCounter)
397400
return new VectorClock(nodeIds, counters);
398401

399402
var pairs = new List<(ushort nodeId, ulong counter)>(nodeIds.Length);
@@ -458,12 +461,18 @@ private static VectorClock CreateCanonical(List<(ushort nodeId, ulong counter)>
458461
if (pairs.Count == 1)
459462
{
460463
var (nodeId, counter) = pairs[0];
464+
if (counter == 0)
465+
return new VectorClock();
466+
461467
return new VectorClock([nodeId], [counter]);
462468
}
463469

464470
var maxByNodeId = new Dictionary<ushort, ulong>(capacity: pairs.Count);
465471
foreach (var (nodeId, counter) in pairs)
466472
{
473+
if (counter == 0)
474+
continue;
475+
467476
ref var existing = ref CollectionsMarshal.GetValueRefOrAddDefault(maxByNodeId, nodeId, out var exists);
468477
if (!exists || counter > existing)
469478
existing = counter;

src/Distributed/VectorClockBuilder.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,9 @@ public VectorClock ToSnapshot()
127127

128128
private void MergeEntry(ushort nodeId, ulong counter)
129129
{
130+
if (counter == 0)
131+
return;
132+
130133
if (_count == 0)
131134
{
132135
EnsureCapacity(1);

tests/VectorClockTests.cs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,28 @@ public void StringSerialization_DuplicateNodes_TakesMaximum()
327327
Assert.Equal("1:4,2:3", parsed.ToString());
328328
}
329329

330+
[Fact]
331+
public void StringSerialization_ZeroCounterEntries_AreCanonicalizedAway()
332+
{
333+
var parsed = VectorClock.Parse("11:704,16:0");
334+
var canonical = VectorClock.Parse("11:704");
335+
336+
Assert.Equal(canonical, parsed);
337+
Assert.Equal(VectorClockOrder.Equal, parsed.Compare(canonical));
338+
Assert.Equal(0UL, parsed.Get(16));
339+
Assert.Equal("11:704", parsed.ToString());
340+
}
341+
342+
[Fact]
343+
public void StringSerialization_ZeroCounterOnly_IsEmpty()
344+
{
345+
var parsed = VectorClock.Parse("16:0");
346+
347+
Assert.Equal(new VectorClock(), parsed);
348+
Assert.True(parsed.IsEmpty);
349+
Assert.Equal(string.Empty, parsed.ToString());
350+
}
351+
330352
[Fact]
331353
public void StringSerialization_UsesInvariantCulture()
332354
{
@@ -395,6 +417,33 @@ public void BinarySerialization_UnsortedInput_Canonicalizes()
395417
Assert.Equal("1:7,2:5", parsed.ToString());
396418
}
397419

420+
[Fact]
421+
public void BinarySerialization_ZeroCounterEntries_AreCanonicalizedAway()
422+
{
423+
var buffer = BuildBinary(
424+
(nodeId: 11, counter: 704UL),
425+
(nodeId: 16, counter: 0UL));
426+
427+
var parsed = VectorClock.ReadFrom(buffer);
428+
var canonical = VectorClock.Parse("11:704");
429+
430+
Assert.Equal(canonical, parsed);
431+
Assert.Equal(14, parsed.GetBinarySize());
432+
Assert.Equal("11:704", parsed.ToString());
433+
}
434+
435+
[Fact]
436+
public void BinarySerialization_ZeroCounterOnly_IsEmpty()
437+
{
438+
var buffer = BuildBinary((nodeId: 16, counter: 0UL));
439+
440+
var parsed = VectorClock.ReadFrom(buffer);
441+
442+
Assert.Equal(new VectorClock(), parsed);
443+
Assert.True(parsed.IsEmpty);
444+
Assert.Equal(4, parsed.GetBinarySize());
445+
}
446+
398447
[Fact]
399448
public void BinarySerialization_CountTooLarge_Throws()
400449
{

0 commit comments

Comments
 (0)