diff --git a/CRDTTests.cs b/CRDTTests.cs index 1a59dfb..8827d6a 100644 --- a/CRDTTests.cs +++ b/CRDTTests.cs @@ -342,5 +342,109 @@ public void InsertingAfterDeletion() "Inserting After Deletion: Node2 should have '__deleted__' column version"); } + [Test] + public void ListCRDTTest() + { + // Create two replicas + ListCRDT replicaA = new ListCRDT(replicaId: 1); // Use replicaId 1 for replica A + ListCRDT replicaB = new ListCRDT(replicaId: 2); // Use replicaId 2 for replica B + + // Replica A inserts "Hello" at index 0 + replicaA.Insert(0, "Hello"); + // Replica A inserts "World" at index 1 + replicaA.Insert(1, "World"); + + // Replica B inserts "Goodbye" at index 0 + replicaB.Insert(0, "Goodbye"); + // Replica B inserts "Cruel" at index 1 + replicaB.Insert(1, "Cruel"); + + // Print initial states + Console.Write("Replica A (Visible): "); + replicaA.PrintVisible(); // Expected: Hello World + + Console.Write("Replica B (Visible): "); + replicaB.PrintVisible(); // Expected: Goodbye Cruel + + // Merge replicas + replicaA.Merge(replicaB); + replicaB.Merge(replicaA); + + // Print merged states + Console.WriteLine("\nAfter merging:"); + + Console.Write("Replica A (Visible): "); + replicaA.PrintVisible(); // Expected order based on origins and IDs + + Console.Write("Replica B (Visible): "); + replicaB.PrintVisible(); // Should match Replica A + + // Perform concurrent insertions + replicaA.Insert(2, "!"); + replicaB.Insert(2, "?"); + + // Merge again + replicaA.Merge(replicaB); + replicaB.Merge(replicaA); + + // Print after concurrent insertions and merging + Console.WriteLine("\nAfter concurrent insertions and merging:"); + + Console.Write("Replica A (Visible): "); + replicaA.PrintVisible(); // Expected: Hello Goodbye World ! ? + + Console.Write("Replica B (Visible): "); + replicaB.PrintVisible(); // Should match Replica A + + // Demonstrate deletions + replicaA.DeleteElement(1); // Delete "Goodbye" + replicaB.DeleteElement(0); // Delete "Hello" + + // Merge deletions + replicaA.Merge(replicaB); + replicaB.Merge(replicaA); + + // Print states after deletions and merging + Console.WriteLine("\nAfter deletions and merging:"); + + Console.Write("Replica A (Visible): "); + replicaA.PrintVisible(); // Expected: World ! ? + + Console.Write("Replica B (Visible): "); + replicaB.PrintVisible(); // Should match Replica A + + // Perform garbage collection + replicaA.GarbageCollect(); + replicaB.GarbageCollect(); + + // Print all elements after garbage collection + Console.WriteLine("\nAfter garbage collection:"); + + Console.WriteLine("Replica A (All Elements):"); + replicaA.PrintAllElements(); + + Console.WriteLine("Replica B (All Elements):"); + replicaB.PrintAllElements(); + + // Demonstrate delta generation and application + // Replica A inserts "New Line" at index 2 + replicaA.Insert(2, "New Line"); + + // Generate delta from Replica A to Replica B + var delta = replicaA.GenerateDelta(replicaB); + + // Apply delta to Replica B + replicaB.ApplyDelta(delta.NewElements, delta.Tombstones); + + // Print final states after delta synchronization + Console.WriteLine("\nAfter delta synchronization:"); + + Console.Write("Replica A (Visible): "); + replicaA.PrintVisible(); // Expected: World ! New Line ? + + Console.Write("Replica B (Visible): "); + replicaB.PrintVisible(); // Should match Replica A + } + // Add more tests here as needed... } \ No newline at end of file diff --git a/ListCRDT.cs b/ListCRDT.cs new file mode 100644 index 0000000..24b5192 --- /dev/null +++ b/ListCRDT.cs @@ -0,0 +1,476 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ForSync.CRDT +{ + /// + /// Represents a unique identifier for a list element. + /// + public struct ElementID : IComparable, IEquatable + { + public ulong ReplicaId { get; set; } + public ulong Sequence { get; set; } + + public int CompareTo(ElementID other) + { + if (Sequence != other.Sequence) + return Sequence.CompareTo(other.Sequence); + return ReplicaId.CompareTo(other.ReplicaId); + } + + public bool Equals(ElementID other) + { + return ReplicaId == other.ReplicaId && Sequence == other.Sequence; + } + + public override bool Equals(object obj) + { + return obj is ElementID other && Equals(other); + } + + public override int GetHashCode() + { + return ReplicaId.GetHashCode() ^ Sequence.GetHashCode(); + } + + public override string ToString() + { + return $"({ReplicaId}, {Sequence})"; + } + } + + /// + /// Comparator for ListElements to establish a total order. + /// + /// Type of the value stored in the list element. + public class ListElementComparer : IComparer> + { + public int Compare(ListElement? a, ListElement? b) + { + if (a == null || b == null) + throw new ArgumentException("Cannot compare null ListElements."); + + bool aIsRoot = (a.Id.ReplicaId == 0 && a.Id.Sequence == 0); + bool bIsRoot = (b.Id.ReplicaId == 0 && b.Id.Sequence == 0); + + if (aIsRoot && !bIsRoot) + return -1; + if (!aIsRoot && bIsRoot) + return 1; + if (aIsRoot && bIsRoot) + return 0; + + // Compare origin_left + if (!Nullable.Equals(a.OriginLeft, b.OriginLeft)) + { + if (!a.OriginLeft.HasValue) + return -1; // a's origin_left is None, so a is first + if (!b.OriginLeft.HasValue) + return 1; // b's origin_left is None, so b is first + + int cmp = a.OriginLeft.Value.CompareTo(b.OriginLeft.Value); + if (cmp != 0) + return cmp; + } + + // Compare origin_right + if (!Nullable.Equals(a.OriginRight, b.OriginRight)) + { + if (!a.OriginRight.HasValue) + return -1; // a is before + if (!b.OriginRight.HasValue) + return 1; // b is before + + int cmp = a.OriginRight.Value.CompareTo(b.OriginRight.Value); + if (cmp != 0) + return cmp; + } + + // If both have the same origins, use ElementID to break the tie + return a.Id.CompareTo(b.Id); + } + } + + /// + /// Represents an element in the list CRDT. + /// + /// Type of the value stored in the list element. + public class ListElement + { + public ElementID Id { get; set; } + public T? Value { get; set; } // Nullable if T is a reference type + public ElementID? OriginLeft { get; set; } + public ElementID? OriginRight { get; set; } + + /// + /// Checks if the element is tombstoned (deleted). + /// + public bool IsDeleted => EqualityComparer.Default.Equals(Value, default(T)); + + public override string ToString() + { + var valueStr = IsDeleted ? "[Deleted]" : $"Value: {Value}"; + var originLeftStr = OriginLeft.HasValue ? OriginLeft.ToString() : "None"; + var originRightStr = OriginRight.HasValue ? OriginRight.ToString() : "None"; + return $"ID: {Id}, {valueStr}, Origin Left: {originLeftStr}, Origin Right: {originRightStr}"; + } + } + + /// + /// Represents the List CRDT using SortedSet. + /// + /// Type of the value stored in the list. + public class ListCRDT + { + private readonly ulong replicaId; // Unique identifier for the replica + private ulong counter; // Monotonically increasing counter for generating unique IDs + private readonly SortedSet> elements; // Set of all elements (including tombstoned) + private readonly Dictionary> elementIndex; // Maps ElementID to ListElement + private readonly IComparer> comparer; + + /// + /// Initializes a new instance of the class with a unique replica ID. + /// + /// Unique identifier for the replica. + public ListCRDT(ulong replicaId) + { + this.replicaId = replicaId; + this.counter = 0; + this.comparer = new ListElementComparer(); + this.elements = new SortedSet>(comparer); + this.elementIndex = new Dictionary>(); + + // Initialize with a root element to simplify origins + ElementID rootId = new ElementID { ReplicaId = 0, Sequence = 0 }; + ListElement rootElement = new ListElement + { + Id = rootId, + Value = default(T), + OriginLeft = null, + OriginRight = null + }; + elements.Add(rootElement); + elementIndex[rootId] = rootElement; + } + + /// + /// Inserts a value at the given index. + /// + /// The index at which to insert the value. + /// The value to insert. + public void Insert(uint index, T value) + { + ElementID newId = GenerateId(); + ElementID? leftOrigin = null; + ElementID? rightOrigin = null; + + // Retrieve visible elements (non-tombstoned) + var visible = GetVisibleElements(); + if (index > visible.Count) + { + index = (uint)visible.Count; // Adjust index if out of bounds + } + + if (index == 0) + { + // Insert at the beginning, right_origin is the first element + if (visible.Count > 0) + { + rightOrigin = visible[0].Id; + } + } + else if (index == visible.Count) + { + // Insert at the end, left_origin is the last element + if (visible.Count > 0) + { + leftOrigin = visible[visible.Count - 1].Id; + } + } + else + { + // Insert in the middle + int prevIndex = (int)index - 1; + int nextIndex = (int)index; + if (prevIndex >= 0) + leftOrigin = visible[prevIndex].Id; + if (nextIndex < visible.Count) + rightOrigin = visible[nextIndex].Id; + } + + // Create a new element with the given value and origins + ListElement newElement = new ListElement + { + Id = newId, + Value = value, + OriginLeft = leftOrigin, + OriginRight = rightOrigin + }; + + Integrate(newElement); + } + + /// + /// Deletes the element at the given index by tombstoning it. + /// + /// The index of the element to delete. + public void DeleteElement(uint index) + { + var visible = GetVisibleElements(); + if (index >= visible.Count) + return; // Index out of bounds, do nothing + + ElementID targetId = visible[(int)index].Id; + if (elementIndex.TryGetValue(targetId, out var existingElement)) + { + // Tombstone the element by setting its value to default + ListElement updated = new ListElement + { + Id = existingElement.Id, + Value = default(T), + OriginLeft = existingElement.OriginLeft, + OriginRight = existingElement.OriginRight + }; + + elements.Remove(existingElement); + elements.Add(updated); + elementIndex[targetId] = updated; + } + } + + /// + /// Merges another into this one. + /// + /// The other to merge. + public void Merge(ListCRDT other) + { + foreach (var elem in other.elements) + { + if (elem.Id.ReplicaId == 0 && elem.Id.Sequence == 0) + continue; // Skip the root element + + Integrate(elem); + } + } + + /// + /// Generates a delta containing operations not seen by the other replica. + /// + /// The other to compare against. + /// A tuple containing new elements and tombstones. + public (List> NewElements, List Tombstones) GenerateDelta(ListCRDT other) + { + List> newElements = new List>(); + List tombstones = new List(); + + foreach (var elem in elements) + { + if (elem.Id.ReplicaId == 0 && elem.Id.Sequence == 0) + continue; // Skip the root element + + if (!other.HasElement(elem.Id)) + { + newElements.Add(elem); + if (elem.IsDeleted) + tombstones.Add(elem.Id); + } + } + + return (newElements, tombstones); + } + + /// + /// Applies a delta to this CRDT. + /// + /// The new elements to integrate. + /// The tombstones to apply. + public void ApplyDelta(List> newElements, List tombstones) + { + // Apply insertions + foreach (var elem in newElements) + { + if (elem.Id.ReplicaId == 0 && elem.Id.Sequence == 0) + continue; // Skip the root element + + var existing = FindElement(elem.Id); + if (existing == null) + { + Integrate(elem); + } + else + { + if (elem.IsDeleted) + { + // Update tombstone + ListElement updated = new ListElement + { + Id = existing.Id, + Value = default(T), + OriginLeft = existing.OriginLeft, + OriginRight = existing.OriginRight + }; + elements.Remove(existing); + elements.Add(updated); + elementIndex[updated.Id] = updated; + } + } + } + + // Apply tombstones + foreach (var id in tombstones) + { + var existing = FindElement(id); + if (existing != null) + { + // Update tombstone + ListElement updated = new ListElement + { + Id = existing.Id, + Value = default(T), + OriginLeft = existing.OriginLeft, + OriginRight = existing.OriginRight + }; + elements.Remove(existing); + elements.Add(updated); + elementIndex[id] = updated; + } + } + } + + /// + /// Retrieves the current list as a list of values. + /// + /// A list of current values in the CRDT. + public List GetValues() + { + return elements + .Where(e => e.Id.ReplicaId != 0 || e.Id.Sequence != 0) + .Where(e => !e.IsDeleted) + .Select(e => e.Value!) + .ToList(); + } + + /// + /// Prints the current visible list for debugging. + /// + public void PrintVisible() + { + foreach (var elem in elements) + { + if (elem.Id.ReplicaId == 0 && elem.Id.Sequence == 0) + continue; // Skip the root element + + if (!elem.IsDeleted) + Console.Write($"{elem.Value} "); + } + Console.WriteLine(); + } + + /// + /// Prints all elements including tombstones for debugging. + /// + public void PrintAllElements() + { + foreach (var elem in elements) + { + Console.WriteLine(elem); + } + } + + /// + /// Performs garbage collection by removing tombstones that are safe to delete. + /// + public void GarbageCollect() + { + var toRemove = elements.Where(e => e.IsDeleted && e.Id.ReplicaId != 0).ToList(); + foreach (var elem in toRemove) + { + elements.Remove(elem); + elementIndex.Remove(elem.Id); + } + } + + #region Private Methods + + /// + /// Generates a unique . + /// + /// A new unique . + private ElementID GenerateId() + { + return new ElementID + { + ReplicaId = replicaId, + Sequence = ++counter + }; + } + + /// + /// Checks if an element exists by its ID. + /// + /// The to check. + /// true if the element exists; otherwise, false. + private bool HasElement(ElementID id) + { + return elementIndex.ContainsKey(id); + } + + /// + /// Finds an element by its ID. + /// + /// The of the element to find. + /// The found , or null if not found. + private ListElement? FindElement(ElementID id) + { + if (elementIndex.TryGetValue(id, out var elem)) + return elem; + return null; + } + + /// + /// Retrieves visible (non-tombstoned) elements in order. + /// + /// A list of visible instances. + private List> GetVisibleElements() + { + return elements + .Where(e => e.Id.ReplicaId != 0 || e.Id.Sequence != 0) + .Where(e => !e.IsDeleted) + .ToList(); + } + + /// + /// Integrates a single element into the CRDT. + /// + /// The new to integrate. + private void Integrate(ListElement newElem) + { + if (elementIndex.ContainsKey(newElem.Id)) + { + var existing = FindElement(newElem.Id); + if (existing != null && newElem.IsDeleted) + { + // Update tombstone + ListElement updated = new ListElement + { + Id = existing.Id, + Value = default(T), + OriginLeft = existing.OriginLeft, + OriginRight = existing.OriginRight + }; + elements.Remove(existing); + elements.Add(updated); + elementIndex[updated.Id] = updated; + } + return; + } + + // Insert the new element + elements.Add(newElem); + elementIndex[newElem.Id] = newElem; + } + + #endregion + } +}