CrdtSync 1.2.1
dotnet add package CrdtSync --version 1.2.1
NuGet\Install-Package CrdtSync -Version 1.2.1
<PackageReference Include="CrdtSync" Version="1.2.1" />
<PackageVersion Include="CrdtSync" Version="1.2.1" />
<PackageReference Include="CrdtSync" />
paket add CrdtSync --version 1.2.1
#r "nuget: CrdtSync, 1.2.1"
#:package CrdtSync@1.2.1
#addin nuget:?package=CrdtSync&version=1.2.1
#tool nuget:?package=CrdtSync&version=1.2.1
CrdtSync
A lightweight State-based LWW-Map CRDT (Last-Writer-Wins Map) sync engine for .NET with batteries included. Mark properties with [Synced], extend SyncableEntity, and get automatic per-field merge with last-writer-wins conflict resolution — plus coalesced push queues, retry helpers, and transport abstractions.
Features
- Per-field conflict resolution — each property tracks its own timestamp, so concurrent edits to different fields on the same entity never conflict
- Last-Writer-Wins — when two devices edit the same field, the most recent write wins automatically
- Hybrid Logical Clock — tolerates clock skew between machines (up to seconds), ensuring correct merge even when system clocks differ
- Coalesced push queue — batches rapid edits into a single sync operation with configurable delay
- Retry-on-conflict helper — download → merge → upload loop with automatic retry on version conflicts
- Transport abstraction —
ISyncTransport<T>interface for pluggable backends (Google Drive, local files, S3, etc.) - Generic sync file envelope —
SyncFile<T>with version counter and schema versioning - Compare-and-swap safe — designed for transports without native optimistic concurrency (e.g. Google Drive)
- Zero configuration — just inherit
SyncableEntity, add[Synced]to properties, and implementSyncKey - Reflection-cached — property metadata is built once per type and reused across all instances
- INotifyPropertyChanged — built-in change notification for MVVM/UI binding
- Logging — optional
Microsoft.Extensions.Loggingintegration; silent by default
Installation
dotnet add package CrdtSync
Quick Start
1. Define your entity
using CrdtSync;
public class TodoItem : SyncableEntity
{
private string _title = "";
private bool _isDone;
private int _priority;
// SyncKey matches entities across devices (must be unique)
public override string SyncKey => _title;
[Synced]
public string Title
{
get => _title;
set => SetField(ref _title, value);
}
[Synced]
public bool IsDone
{
get => _isDone;
set => SetField(ref _isDone, value);
}
[Synced(ClearValue = 0)]
public int Priority
{
get => _priority;
set => SetField(ref _priority, value);
}
}
2. Edit entities (timestamps are tracked automatically)
var item = new TodoItem { SuppressTimestamp = false };
item.Title = "Buy milk";
item.Priority = 3;
// FieldTimestamps now contains HLC timestamps for "Title" and "Priority"
3. Merge from a remote copy
// Single entity merge
bool changed = localItem.MergeFrom(remoteItem);
// Bulk merge — matches by SyncKey, returns changed count + new entities
var (mergeCount, newItems) = SyncableEntity.MergeAll(localList, remoteList, "Device1");
localList.AddRange(newItems);
4. Serialize and transport however you like
using System.Text.Json;
// Serialize
var json = JsonSerializer.Serialize(localList);
// Deserialize (SuppressTimestamp defaults to true, so deserialization won't overwrite timestamps)
var remoteList = JsonSerializer.Deserialize<List<TodoItem>>(json);
Coalesced Push Queue
When users make rapid edits (typing, toggling checkboxes), you don't want to trigger a sync for each keystroke. CoalescedPushQueue batches these into a single operation after a configurable delay:
var pushQueue = new CoalescedPushQueue(delayMs: 200);
// In your property-changed handler:
pushQueue.Enqueue("todos", async () => await SyncToRemoteAsync());
// Before app shutdown — flush any pending edits:
await pushQueue.FlushAsync();
// Listen for flush completions:
pushQueue.FlushCompleted += allOk =>
{
if (!allOk) Log.Warning("Some push actions failed");
};
Each Enqueue call resets the timer. Actions are only flushed when the timer fires (no new edits within the delay window). All actions for the same file key are executed sequentially in one batch.
Sync Retry Helper
SyncRetryHelper<T> implements the standard download → merge → upload retry loop for CRDT sync. When a version conflict is detected (another device uploaded concurrently), it re-downloads, re-merges, and retries:
// Implement ISyncTransport<T> for your backend
public class MyDriveTransport : ISyncTransport<TodoItem>
{
public long LastKnownVersion { get; private set; }
public async Task<(List<TodoItem> Records, bool VersionOk)> DownloadAsync()
{
// Download from remote, check version against LastKnownVersion
}
public async Task UploadAsync(List<TodoItem> records)
{
// Upload to remote, increment version
}
}
// Use the retry helper
var transport = new MyDriveTransport();
var helper = new SyncRetryHelper<TodoItem>(transport);
var result = await helper.MergeAndUploadAsync(
localEntities: myLocalItems,
deviceName: Environment.MachineName,
beforeMerge: () => RefreshFromDatabaseAsync() // Optional: re-stamp before each attempt
);
Console.WriteLine($"Merged {result.MergeCount} entities, {result.NewEntities.Count} new, {result.Attempts} attempts");
Sync File Envelope
SyncFile<T> is a generic JSON envelope for synced entity collections with version tracking:
var syncFile = new SyncFile<TodoItem>
{
SchemaVersion = 3,
Version = 42,
LastModified = DateTime.UtcNow,
Records = myItems
};
var json = JsonSerializer.Serialize(syncFile);
Use the version counter for compare-and-swap: if the remote version differs from your last-known version, another device uploaded concurrently and you need to re-merge before uploading.
Transport Interface
ISyncTransport<T> provides a minimal abstraction for sync backends:
public interface ISyncTransport<T> where T : SyncableEntity
{
Task<(List<T> Records, bool VersionOk)> DownloadAsync();
Task UploadAsync(List<T> records);
long LastKnownVersion { get; }
}
Implementations handle version tracking and compare-and-swap internally. The SyncRetryHelper<T> builds the retry loop on top.
Hybrid Logical Clock (HLC)
CrdtSync uses a Hybrid Logical Clock to generate timestamps instead of raw DateTime.UtcNow. This solves a common problem with multi-device sync: clock skew.
When two machines have slightly different system clocks (even a few seconds), a plain DateTime.UtcNow comparison can give the wrong merge result — an edit made later in real time can lose to an earlier edit from a machine with a faster clock.
The HLC guarantees:
- Timestamps are monotonically increasing — every new timestamp is strictly greater than the previous one
- When merging remote data, the clock witnesses remote timestamps and advances past them
- A subsequent local edit always gets a timestamp greater than any previously seen value
This means that as long as devices sync periodically, the HLC self-corrects for clock drift automatically. No NTP configuration required.
// The HLC is used automatically by SetField.
// For testing with simulated clock skew:
HybridClock.SetPhysicalClock(() => DateTime.UtcNow.AddSeconds(-5));
HybridClock.Reset(); // Reset state (for test isolation)
API Reference
SyncableEntity (abstract base class)
| Member | Description |
|---|---|
abstract string SyncKey |
Unique key to match entities across devices (e.g. Name, Id) |
Dictionary<string, DateTime> FieldTimestamps |
Per-field timestamps for merge decisions |
bool SuppressTimestamp |
When true, property changes don't update timestamps. Defaults to true — set to false after construction/deserialization |
bool MergeFrom(SyncableEntity other) |
Merge per-field from another entity. Returns true if any value changed |
void Clear() |
Reset all [Synced] properties to their ClearValue defaults |
static (int, List<T>) MergeAll<T>(...) |
Bulk merge remote list into local list by SyncKey |
static Action<T, Random>[] BuildRandomEditActions<T>() |
Generate random edit actions per [Synced] property (useful for testing) |
static IReadOnlyList<SyncedPropertyMeta> GetSyncedProperties(Type) |
Get cached reflection metadata for a type |
protected bool SetField<T>(ref T, T, string?) |
Set a backing field, auto-update timestamp via HLC, and raise PropertyChanged |
CoalescedPushQueue
| Member | Description |
|---|---|
CoalescedPushQueue(int delayMs = 200, ILogger? logger = null) |
Create queue with configurable delay |
void Enqueue(string fileKey, Func<Task> action) |
Queue an action. Resets the coalesce timer |
Task FlushAsync() |
Immediately drain and execute all queued actions |
bool HasPending |
True if there are actions waiting to be flushed |
event Action<bool>? FlushCompleted |
Raised after each flush (true = all actions succeeded) |
SyncRetryHelper<T>
| Member | Description |
|---|---|
SyncRetryHelper(ISyncTransport<T>, ILogger?) |
Create helper with a transport backend |
int MaxRetries { get; set; } |
Maximum retry attempts on version conflict (default: 3) |
Task<MergeResult<T>> MergeAndUploadAsync(...) |
Download → merge → upload with retry loop |
MergeResult<T>
| Member | Description |
|---|---|
int MergeCount |
Number of existing entities updated by remote data |
List<T> NewEntities |
Entities from remote not present locally |
int Attempts |
Number of attempts taken (1 = no retries) |
SyncFile<T>
| Member | Description |
|---|---|
int SchemaVersion |
Envelope format version (default: 3) |
long Version |
Linear version counter for compare-and-swap |
DateTime LastModified |
UTC timestamp of last upload |
List<T> Records |
The synced entity collection |
ISyncTransport<T>
| Member | Description |
|---|---|
Task<(List<T>, bool)> DownloadAsync() |
Download records + version check |
Task UploadAsync(List<T> records) |
Upload merged records |
long LastKnownVersion |
Last-known remote version |
HybridClock (static)
| Member | Description |
|---|---|
static DateTime Now() |
Returns a monotonically increasing timestamp (max of physical clock and last known + 1ms) |
static void Witness(DateTime remoteTime) |
Advances the clock past a remote timestamp. Called automatically during MergeFrom |
static void Reset() |
Resets clock state (for testing only) |
static void SetPhysicalClock(Func<DateTime>?) |
Overrides the physical clock source (for testing clock skew) |
[Synced] attribute
| Property | Type | Default | Description |
|---|---|---|---|
ClearValue |
object? |
null |
Value used by Clear(). If null, uses default(T) (empty string for string) |
DbType |
string |
"" |
Optional SQLite column type hint for DB migration scenarios |
Logging
CrdtSync uses Microsoft.Extensions.Logging. By default logging is disabled (NullLogger). To enable:
// Option A: Pass an ILogger directly
SyncableEntity.SetLogger(myLogger);
// Option B: Pass an ILoggerFactory
SyncableEntity.SetLogger(loggerFactory);
// With Serilog (add Serilog.Extensions.Logging package):
using Serilog.Extensions.Logging;
SyncableEntity.SetLogger(new SerilogLoggerFactory(Log.Logger).CreateLogger("CrdtSync"));
How It Works
CrdtSync implements a State-based LWW-Map CRDT with Hybrid Logical Clock:
- Each entity has a
FieldTimestampsdictionary mapping property names to their last-edit time - When
SetFieldis called (andSuppressTimestampisfalse), the field's timestamp is set viaHybridClock.Now()— which returnsmax(physicalClock, lastKnown + 1ms) - During
MergeFrom, each[Synced]property is compared by timestamp — the newer value wins. Remote timestamps are witnessed by the HLC so future local edits always get a greater timestamp MergeAllmatches entities across lists usingSyncKey, merges existing ones, and returns entities that only exist remotelyCoalescedPushQueuebatches rapid edits so your transport isn't overwhelmedSyncRetryHelperhandles version conflicts automatically with download → re-merge → upload retry
This means:
- No central server needed — any device can merge with any other device
- Offline-friendly — edits accumulate timestamps locally and resolve on next sync
- No data loss — only individual fields are overwritten, not entire objects
- Clock-skew tolerant — the HLC ensures correct ordering even when system clocks differ by seconds
Supported Property Types
Any type that works with EqualityComparer<T>.Default and can be set via reflection:
string(default clear value:"")int,bool,double,float,decimalDateTime,Guid- Enums
- Any serializable reference type
Requirements
- .NET 8.0 or later
Microsoft.Extensions.Logging.Abstractions(included automatically)
License
MIT
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net8.0
- Microsoft.Extensions.Logging.Abstractions (>= 9.0.4)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.