Warning
This project is in early development and not intended for production use.
CRDT-Lite is a lightweight implementation of Conflict-free Replicated Data Types (CRDTs) in C++. It provides a generic CRDT structure suitable for distributed systems requiring eventual consistency. CRDT-Lite is currently being used in Formabble, a collaborative game engine, and will be integrated into a derived product that we will announce soon.
- CRDT-Lite
- Table of Contents
- Features
- Usage
- Quick Start
- Implementation Details
- External Version Tracking
- Design Considerations
- Limitations
- FAQ
- Future Enhancements
- Contributing
- License
- Extras
- Generic CRDT Structure: Supports custom key and value types.
- Logical Clock: Maintains causality across events.
- Fine-Grained Conflict Resolution: Based on column versions, database versions, and node IDs.
- CRUD Operations: Insert, update, and delete operations with tombstone handling.
- Efficient Merging: Synchronizes state across nodes effectively.
- Change Compression: Optimizes change propagation by removing redundant changes.
- Multi-Language Support: Implemented in C++ for flexibility and performance.
- External Version Tracking: Robust synchronization management without requiring identical logical clocks across nodes.
CRDT-Lite is actively used in Formabble, a collaborative game engine designed to facilitate real-time collaboration in game development. By leveraging CRDT-Lite, Formabble ensures consistent and conflict-free data synchronization across multiple users and instances. Additionally, CRDT-Lite will be integrated into a derived product that we will announce soon, further demonstrating its versatility and effectiveness in real-world applications.
- Ensure a C++20 Compatible Compiler: Make sure you have a compiler that supports C++20.
- Compile and Run:
g++ -std=c++20 -o crdt tests.cpp && ./crdt
CRDT-Lite's C++ implementation is designed with core concepts and principles to ensure consistency and efficiency.
- LogicalClock: Manages causality across events.
class LogicalClock { uint64_t time_; public: uint64_t tick() { return ++time_; } uint64_t update(uint64_t received_time) { time_ = std::max(time_, received_time); return ++time_; } void set_time(uint64_t t) { time_ = t; } uint64_t current_time() const { return time_; } };
- ColumnVersion: Tracks version information for each column in a record.
struct ColumnVersion { uint64_t col_version; uint64_t db_version; CrdtNodeId node_id; };
- Record: Represents individual records with fields and their version information.
- CRDT: The main structure managing overall state, including data records and tombstones.
- External Version Tracking: Maintains synchronization state between nodes.
CRDT-Lite employs a straightforward approach to manage causality without the complexity of vector clocks:
- Logical Clock: Each node maintains a single logical clock that increments with each operation.
- Column Version: Each field in a record has its own version information, replacing the need for vector clocks.
- External Version Tracking: Tracks
last_db_version
to manage synchronization efficiently.
Conflicts are resolved deterministically using a combination of:
- Column Version: Tracks changes at the field level.
- Database Version (
db_version
): Provides a global ordering of changes. - Node ID: Breaks ties between concurrent changes from different nodes.
The merge algorithm prioritizes these factors in the above order to ensure consistent conflict resolution across all nodes.
Each operation (insert, update, delete) generates a Change
object for incremental updates:
template <typename K, typename V>
struct Change {
K record_id;
std::optional<CrdtString> col_name;
std::optional<V> value;
uint64_t col_version;
uint64_t db_version;
CrdtNodeId node_id;
};
This design minimizes bandwidth usage by transmitting only the necessary changes during synchronization. The compress_changes
method further optimizes change propagation by removing redundant changes.
Deleted records are marked with tombstones to prevent their accidental resurrection during merges:
CrdtSet<K> tombstones_;
Deletions are represented by changes with col_name
set to std::nullopt
.
The merge process ensures eventual consistency by:
- Comparing Incoming Changes: Each incoming change is evaluated against the local version.
- Applying Changes: If the incoming change is newer or wins the conflict resolution, it is applied.
- Updating Local Versions: Local version information is updated accordingly.
This deterministic process guarantees that all nodes reach a consistent state.
External version tracking in CRDT-Lite is designed to be managed by the users of the library. Users are responsible for maintaining and persisting the following version information:
- Last Version Integrated from Remote (
last_db_version
): Tracks the highestdb_version
received from a specific remote node.
The sync_nodes
function updates last_db_version
based on the maximum db_version
in the changes being synchronized.
CRDT-Lite is crafted with a focus on simplicity, efficiency, and scalability, addressing common challenges in distributed systems.
- Logical Clock: Combines with external version tracking to maintain causality without synchronized clocks.
- Efficiency: Handles clock drift between nodes effectively.
- Fine-Grained: Operates at the field level for precise conflict management.
- Deterministic: Uses a combination of column version, database version, and node ID to resolve conflicts predictably.
- Minimal Storage Overhead: Stores only the current state without historical data.
- Change-Based Operations: Facilitates efficient incremental updates and synchronization.
- Change Compression: Optimizes change propagation by removing redundant changes.
- Supports Many Nodes: Utilizes node IDs to handle a large or unpredictable number of nodes.
- Optimized for Multiple Fields: Efficiently manages records with numerous fields.
- Thread Safety: The current implementation is not thread-safe. Concurrency support is not planned.
- Network Transport Layer: Not included. Users must implement their own synchronization mechanisms.
Q: How does CRDT-Lite handle tombstones for deleted records?
A: Tombstones are maintained to properly handle deletions across the distributed system. Deletions are represented by changes with col_name
set to std::nullopt
.
Q: Is there support for composite fields or atomic updates to multiple related fields?
A: Currently, fields are treated individually. A Redis-like approach with long keys (e.g., fbl/pose/translation
) can handle composite data. Full support for composite fields may be added in future updates.
Q: How does the system ensure security?
A: CRDT-Lite focuses on data consistency and conflict resolution. Security measures like encryption should be implemented at the network layer or application layer as appropriate for your use case.
Q: Can the system handle large numbers of concurrent updates?
A: Yes, the conflict resolution mechanism ensures that all nodes will eventually reach a consistent state, even with many concurrent updates. The resolution is deterministic, using node ID for tiebreaking when necessary.
Q: How efficient is the synchronization process?
A: CRDT-Lite uses a change-based operation system with change compression, allowing for efficient incremental updates between frequently communicating nodes. This significantly reduces overhead in lively connected systems.
- Tombstone Garbage Collection: Implement mechanisms to remove old tombstones.
- Bandwidth Optimization: Further enhance efficiency for large datasets.
- Custom Merge Functions: Allow users to define specific merge behaviors for unique use cases.
Contributions are welcome! Please feel free to submit a Pull Request. Your contributions help improve CRDT-Lite for everyone.
This project is licensed under the MIT License - see the LICENSE file for details.
CRDT-Lite offers a streamlined approach to conflict-free replicated data types, balancing simplicity and efficiency without compromising on the core benefits of CRDTs. By focusing on a simplified clock mechanism and robust conflict resolution, CRDT-Lite is well-suited for applications that prioritize scalability and low overhead in distributed environments.
The merge operation in CRDT-Lite is designed to ensure eventual consistency across distributed nodes by deterministically resolving conflicts when changes from different nodes are integrated. The core of this process lies in the conflict resolution algorithm, which decides whether to accept or reject incoming changes based on a set of versioning attributes.
The conflict resolution algorithm uses the following attributes, in order of priority:
- Column Version (
col_version
): Represents the version of a specific column (field) within a record. It increments with each change to that column. - Database Version (
db_version
): A logical clock that provides a causal ordering of changes across the entire database. - Node ID (
node_id
): A unique identifier for each node in the distributed system. It helps break ties whencol_version
anddb_version
are equal.
When integrating incoming changes, the algorithm processes each change individually using the following steps:
- Update Logical Clock: The local logical clock (
db_version
) is updated to be at least as high as the incomingdb_version
. - Retrieve Local Version Information: For the record and column in question, retrieve the local
col_version
,db_version
, andnode_id
. - Determine Acceptance of Change: Compare the incoming change's attributes with the local attributes in the following order:
- Column Version (
col_version
):- If incoming
col_version
> localcol_version
: Accept the change. - If incoming
col_version
< localcol_version
: Reject the change. - If equal, proceed to the next attribute.
- If incoming
- Database Version (
db_version
):- If incoming
db_version
> localdb_version
: Accept the change. - If incoming
db_version
< localdb_version
: Reject the change. - If equal, proceed to the next attribute.
- If incoming
- Node ID (
node_id
):- If incoming
node_id
> localnode_id
: Accept the change. - If incoming
node_id
<= localnode_id
: Reject the change.
- If incoming
- Column Version (
- Apply Accepted Changes:
- If accepted:
- Updates and Insertions: Update the field's value and version information.
- Deletions: Mark the record as deleted (tombstoned) and remove its data.
- If rejected: Discard the incoming change.
- If accepted:
The merge algorithm is sound because it ensures that:
- Determinism: All nodes apply the same conflict resolution logic, leading to the same final state when all changes have been propagated.
- Consistency: The use of versioning attributes provides a total ordering of changes, allowing nodes to agree on which changes are more recent or authoritative.
- Eventual Consistency: By deterministically resolving conflicts and accepting the most recent changes based on the defined attributes, all nodes will eventually converge to the same state.
The order in which attributes are compared is critical for maintaining the correctness and soundness of the conflict resolution. Here's why each attribute is compared before the next:
- Field-Level Granularity:
col_version
provides a version number specific to each field (column) within a record. By prioritizingcol_version
, we ensure that changes to individual fields are accurately tracked and resolved. - Independent Field Updates: Different fields in the same record can be updated independently on different nodes. Comparing
col_version
first allows the algorithm to correctly resolve changes to specific fields without being affected by unrelated changes elsewhere in the database. - Avoiding Unnecessary Rejections: If we compared
db_version
first, a change with a higherdb_version
but lowercol_version
might incorrectly overwrite a more recent field-specific change, leading to data loss or inconsistency.
- Causal Ordering:
db_version
acts as a logical clock that captures the causal history of changes across the entire database. By comparingdb_version
aftercol_version
, we respect the causal relationships between changes. - Global Consistency: Using
db_version
ensures that changes are ordered consistently across nodes, even whencol_version
is the same.
- Node A updates field
F
in recordR
:col_version = 2
,db_version = 5
,node_id = 1
.
- Node B updates field
F
in recordR
concurrently:col_version = 2
,db_version = 6
,node_id = 2
.
Conflict Resolution:
- Compare
col_version
:- Both are
2
; proceed todb_version
.
- Both are
- Compare
db_version
:- Node B's
db_version
(6
) > Node A'sdb_version
(5
); accept Node B's change.
- Node B's
- Node A deletes record
R
:- Updates
"__deleted__"
column withcol_version = 3
,db_version = 8
,node_id = 1
.
- Updates
- Node B updates field
F
in recordR
:col_version = 2
,db_version = 7
,node_id = 2
.
Conflict Resolution for field F
:
- Compare
col_version
:- Node B's
col_version
(2
) < Node A'scol_version
(3
for"__deleted__"
); reject Node B's change.
- Node B's
Conflict Resolution for "__deleted__"
:
- Since only Node A has a change to
"__deleted__"
, and Node B doesn't have a local version for it, Node B will accept Node A's deletion.
- Preservation of Field-Specific Updates: By comparing
col_version
first, we ensure that the most recent changes to a field are preserved, even if the overalldb_version
is lower. - Avoiding Overwrites from Unrelated Changes: A higher
db_version
does not necessarily mean that a change to a specific field is more recent. If we compareddb_version
first, a change that didn't affect a particular field could incorrectly overwrite a more recent change to that field. - Correct Handling of Deletions: Deletions are treated as changes to the
"__deleted__"
column. By comparingcol_version
first, we can correctly resolve conflicts between deletions and updates to other fields.
- Soundness: The merge algorithm is sound because it ensures deterministic conflict resolution based on a well-defined ordering of versioning attributes.
- Importance of Order: Comparing
col_version
beforedb_version
is crucial for accurately resolving conflicts at the field level and maintaining data integrity. - Uniform Application: The conflict resolution logic applies uniformly to all columns, including the
"__deleted__"
column used for deletions, simplifying the algorithm and avoiding special cases.