DSON

repository·main·Indexed 41 days ago

https://github.com/helsing-ai/dson

A Rust implementation of a delta-state Conflict-Free Replicated Datatype (CRDT) designed for JSON-like data structures. It provides low-level primitives including OrMap (Observed-Remove Map), OrArray (Observed-Remove Array), and MvReg (Multi-Value Register) to enable efficient multi-writer collaboration in high-latency or low-bandwidth environments using causal history and delta-mutations.

Tokens
15.2K
Snippets
47
Records
64
Agent score
47%

What's inside DSON

  1. Understand the scope and limitations of the DSON crate

    main

    DSON is a low-level library providing core data structures and algorithms for delta-state CRDTs.

    Key constraints for developers:

    • No Networking: The crate does not include any networking protocols. You are responsible for implementing the transport layer (e.g., WebSockets, P2P, etc.) to broadcast deltas between replicas.
    • Abstraction Layer: Because it is low-level, users are encouraged to build a typed abstraction layer on top of the provided primitives for their specific use cases.
  2. What are the core DSON primitives?

    main

    DSON is built from three fundamental, composable CRDT (Conflict-Free Replicated Datatype) primitives that can be nested to create complex, JSON-like data structures:

    • OrMap: An Observed-Remove Map that maps string keys to other CRDT values.
    • OrArray: An Observed-Remove Array providing a list-like structure.
    • MvReg: A Multi-Value Register used for storing primitive values. In the event of concurrent writes, the register holds all conflicting values.

    All modifications to these structures produce a delta, which is a compact set of changes designed to be transmitted efficiently to other replicas.

  3. How do Observed-Remove (OR) semantics work in DSON?

    main

    DSON utilizes Observed-Remove (OR) semantics for its maps and arrays. Under this model:

    1. An element can only be removed if its addition has been previously observed.
    2. If an element is updated concurrently with its removal, the update wins and the element is preserved.

    This approach, combined with DSON's causal history model, allows for tombstone-free removals, preventing the unbounded metadata growth typically seen in long-lived documents in other CRDT implementations.

  4. Run DSON benchmarks

    main

    The repository includes two benchmark harnesses for performance testing.

    Tango (Regression Testing)

    Used for paired-testing to detect performance regressions.

    # Establish baseline
    cargo bench --bench tango
    
    # After making changes, compare against baseline
    cargo bench --bench tango -- compare
    
    # Filter specific benchmarks
    cargo bench --bench tango -- compare -f "transaction::map::*"
    
    # Quick absolute timings (non-comparative mode)
    cargo bench --bench tango -- solo -f "transaction::map::*" -s 100

    IAI-Callgrind (Instruction-level Profiling)

    Requires Linux and Valgrind. Provides instruction-level profiling.

    # Run IAI benchmarks
    cargo bench --bench iai
    cargo bench --bench nested_transactions
  5. What is an OrArray and how does it work?

    main

    An OrArray is an Observed-Remove Array, a list-like CRDT that supports concurrent insertions, updates, removals, and moves. It is one of the three core primitives in DSON, alongside OrMap and MvReg.

    Stable Positioning

    Instead of integer indices (which would require updating all subsequent elements upon insertion/deletion), OrArray uses stable identifiers in the form of real numbers (f64).

    • To insert an element between positions $p_1$ and $p_2$, use Position::between to calculate the new position as $(p_1 + p_2)/2$.
    • The array is represented as a sequence of values sorted by their position in ascending order.
    • Because of concurrency, a single element might have a set of possible positions. Replicas resolve this by deterministically choosing the maximum position from the set.

    Unique Identifiers

    Each element is identified by a Uid (derived from the Dot used during its creation). This allows the system to track the same element even if it moves to different positions due to concurrent operations.

    Usage Pattern

    OrArray is typically wrapped in a CausalDotStore. You perform modifications by creating a "delta" CRDT and then merging it back into the original store using .join().

    // Create a new CausalDotStore containing an OrArray.
    let mut doc: CausalDotStore<OrArray> = CausalDotStore::new();
    let id = Identifier::new(0, 0);
    
    // Create a delta to insert a value at the beginning of the array.
    let delta = dson::api::array::insert(
        |cc, id| MvReg::default().write(MvRegValue::U64(42), cc, id).map_store(Value::Register),
        doc.store.len(),
    )(&doc.store, &doc.context, id);
    
    // Merge the delta into the document.
    doc = doc.join(delta, &mut DummySentinel).unwrap();
    
    // The value can now be read from the array.
    let array = doc.store.value().unwrap();
    let val = doc.store.get(0).unwrap();
    assert_eq!(val.reg.value().unwrap(), &MvRegValue::U64(42));
  6. Use CollapsedValue for conflict-less snapshots

    main

    The CollapsedValue enum provides an ergonomic, read-only view of a CRDT that assumes no conflicts exist. If a conflict is encountered during the snapshot process, an error is returned.

    Variants:

    • Register(&'doc MvRegValue): A single value from a register.
    • Map(OrMap<'doc, String, CollapsedValue<'doc, Custom>>): An observed-remove map.
    • Array(OrArray<CollapsedValue<'doc, Custom>>): An observed-remove array.
    • Custom(Custom::Value): A custom CRDT type's single value.
    // CollapsedValue is used when you want a 'normal' data view
    match collapsed_snapshot {
        CollapsedValue::Register(val) => { /* val is a single MvRegValue */ }
        CollapsedValue::Map(map) => { /* access map */ }
        // ...
    }
  7. How Sentinels work to observe and validate CRDT changes

    main

    Sentinels are types used to inspect, observe, or validate changes being applied to a CRDT. They are primarily used for schema validation, logging, or collecting metrics during document mutations (such as during a DotStoreJoin::join).

    There are two main categories of Sentinels based on their error handling:

    • Observers: Sentinels where Error = Infallible. They only observe changes and cannot stop a mutation.
    • Validators: Sentinels that can return an Err. They can stop a change from being applied if it violates a rule.

    To effectively track where in the document tree a change is occurring, a Sentinel should implement the Visit<K> trait alongside other specialized traits. This allows the Sentinel to maintain an internal pointer to the current node by responding to traversal events.

  8. Use AllValues for conflict-preserving snapshots

    main

    The AllValues enum is a read-only representation of a CRDT that preserves all concurrent values in multi-value registers. It mirrors the structure of the actual CRDTs but cannot be modified.

    Variants:

    • Register(MvReg<'doc>): A multi-value register containing all concurrent values.
    • Map(OrMap<'doc, String, AllValues<'doc, Custom>>): An observed-remove map.
    • Array(OrArray<AllValues<'doc, Custom>>): An observed-remove array.
    • Custom(Custom::Values): A custom CRDT type's values.
    // AllValues is an enum representing the full state including conflicts
    match all_values_snapshot {
        AllValues::Register(mvreg) => {
            for val in mvreg.iter() { /* handle multiple values */ }
        }
        AllValues::Map(map) => { /* access map keys/values */ }
        // ...
    }
  9. How to take snapshots of CRDTs using the ToValue trait

    main

    DSON provides two ways to create read-only, immutable snapshots of a CRDT's state via the ToValue trait. This allows you to inspect the data without modifying the underlying CRDT.

    1. Conflict-preserving snapshots (ToValue::values()): Returns an AllValues snapshot. In this mode, multi-value registers (MvReg) will contain all concurrently written values. Use this when you need to manually handle conflicts (e.g., merging or presenting multiple options to a user).
    2. Conflict-collapsing snapshots (ToValue::value()): Returns a CollapsedValue snapshot. This assumes no conflicts exist. If any MvReg contains more than one value, this method will return an Err. Use this for a standard, ergonomic data view when conflicts are not expected or have already been resolved.
    // Example conceptual usage
    let snapshot_all = crdt.values(); // Returns AllValues (preserves conflicts)
    let snapshot_collapsed = crdt.value(); // Returns Result<CollapsedValue, ...> (errors on conflicts)
  10. How Observed-Remove (OR) semantics work in OrArray

    main

    The OrArray follows Observed-Remove semantics. This means that updates (like apply) that are concurrent with a delete or clear operation may still persist.

    Specifically, if a user performs an update on an element that another user concurrently deletes, the update is not 'observed' by the delete operation. When the states are joined, the update can survive, potentially resulting in an array that contains the updated element even though a delete was issued. This is visible in the nested_update_vs_remove test case where an update to a nested map survives an outer array deletion.

  11. Use MapTransaction for atomic mutations on OrMaps

    main

    A MapTransaction provides an ergonomic API for performing multiple mutations on a CausalDotStore<OrMap<K, C>> atomically.

    Key Behaviors

    • Eager Application: Mutations (write, remove, clear) apply immediately to a cloned version of the store. This means get() calls within the same transaction see uncommitted changes (read-uncommitted semantics).
    • Automatic Rollback: If the transaction is dropped without calling commit(), all changes are discarded, and the original store remains unchanged.
    • Exclusive Borrowing: The transaction exclusively borrows the underlying store, preventing other access until the transaction is committed or dropped.
    • Commit: Calling commit() swaps the modified clone back into the original store and returns a Delta containing all accumulated changes for network transmission.
    use dson::{CausalDotStore, Identifier, OrMap, transaction::MapTransaction};
    
    let mut store = CausalDotStore::<OrMap<String>>::default();
    let id = Identifier::new(0, 0);
    
    let mut tx = MapTransaction::new(&mut store, id);
    // Make mutations...
    let delta = tx.commit();
  12. Read uncommitted changes in ArrayTransaction

    main

    Because ArrayTransaction applies changes eagerly to its internal working_store, you can read back values you have just inserted within the same transaction using .get(idx).

    let mut tx = ArrayTransaction::new(&mut store, id);
    
    // Insert a value
    tx.insert_register(0, MvRegValue::String("hello".to_string()));
    
    // Read the value back immediately (before commit)
    match tx.get(0) {
        Some(CrdtValue::Register(reg)) => {
            // This will succeed and contain "hello"
        }
        _ => {}
    }
    
    let delta = tx.commit();