Automerge Conflict Resolution
Mix.install([
{:kino, "~> 0.15.3"},
{:iroh_ex, path: Path.expand("../")},
{:rustler, "~> 0.36.1", override: true},
{:rustler_precompiled, "~> 0.7"},
{:jason, "~> 1.4"}
])
Introduction
This livebook explores how Automerge handles conflicts when multiple users edit the same document simultaneously.
Key concepts:
- Concurrent edits - Changes made without knowledge of each other
- Automatic resolution - Automerge resolves conflicts deterministically
- Last-writer-wins for scalars - When multiple actors set the same key
- Sequence preservation for lists - Concurrent list edits are interleaved
- Counter convergence - Counters always sum correctly
Setup
alias IrohEx.{Native, Automerge, NodeConfig}
# Create a node
config = NodeConfig.build()
node = Native.create_node(self(), config)
IO.puts("Node created")
Scenario 1: Concurrent Map Edits (Different Keys)
When two branches edit different keys, both changes are preserved.
# Create base document
doc = Automerge.new(node)
:ok = Automerge.put(node, doc, [], "base", "original value")
IO.puts("Initial state:")
IO.puts(Automerge.to_json(node, doc))
# Fork to simulate two users
alice_doc = Automerge.fork(node, doc)
bob_doc = Automerge.fork(node, doc)
# Alice edits key_a
:ok = Automerge.put(node, alice_doc, [], "alice_field", "Alice was here")
# Bob edits key_b
:ok = Automerge.put(node, bob_doc, [], "bob_field", "Bob was here")
IO.puts("\nAlice's view:")
IO.puts(Automerge.to_json(node, alice_doc))
IO.puts("\nBob's view:")
IO.puts(Automerge.to_json(node, bob_doc))
# Merge both into the original
{:ok, alice_bytes} = Automerge.save(node, alice_doc)
{:ok, bob_bytes} = Automerge.save(node, bob_doc)
:ok = Automerge.merge(node, doc, alice_bytes)
:ok = Automerge.merge(node, doc, bob_bytes)
IO.puts("\nMerged result (both changes preserved):")
IO.puts(Automerge.to_json(node, doc))
Scenario 2: Concurrent Map Edits (Same Key)
When two branches edit the same key, Automerge uses "last-writer-wins" based on actor ID ordering.
# Create base document
doc2 = Automerge.new(node)
:ok = Automerge.put(node, doc2, [], "status", "pending")
IO.puts("Initial state:")
IO.puts(Automerge.to_json(node, doc2))
# Fork
alice_doc2 = Automerge.fork(node, doc2)
bob_doc2 = Automerge.fork(node, doc2)
# Both edit the SAME key concurrently
:ok = Automerge.put(node, alice_doc2, [], "status", "approved by Alice")
:ok = Automerge.put(node, bob_doc2, [], "status", "rejected by Bob")
IO.puts("\nAlice sets status to: #{Automerge.get(node, alice_doc2, [], "status")}")
IO.puts("Bob sets status to: #{Automerge.get(node, bob_doc2, [], "status")}")
# Merge into original
{:ok, alice_bytes2} = Automerge.save(node, alice_doc2)
{:ok, bob_bytes2} = Automerge.save(node, bob_doc2)
:ok = Automerge.merge(node, doc2, alice_bytes2)
:ok = Automerge.merge(node, doc2, bob_bytes2)
final_status = Automerge.get(node, doc2, [], "status")
IO.puts("\nFinal status after merge: #{final_status}")
IO.puts("(Winner determined by actor ID ordering)")
IO.puts("\nFull merged state:")
IO.puts(Automerge.to_json(node, doc2))
Scenario 3: Concurrent List Operations
When two users add items to a list concurrently, items are interleaved deterministically.
# Create document with a list
doc3 = Automerge.new(node)
{:ok, _} = Automerge.create_list(node, doc3, [], "items")
:ok = Automerge.list_push(node, doc3, ["items"], "item_0")
IO.puts("Initial list:")
IO.puts(Automerge.to_json(node, doc3))
# Fork
alice_doc3 = Automerge.fork(node, doc3)
bob_doc3 = Automerge.fork(node, doc3)
# Alice adds items
:ok = Automerge.list_push(node, alice_doc3, ["items"], "alice_item_1")
:ok = Automerge.list_push(node, alice_doc3, ["items"], "alice_item_2")
# Bob adds items
:ok = Automerge.list_push(node, bob_doc3, ["items"], "bob_item_1")
:ok = Automerge.list_push(node, bob_doc3, ["items"], "bob_item_2")
IO.puts("\nAlice's list:")
for i <- 0..(Automerge.list_length(node, alice_doc3, ["items"]) - 1) do
IO.puts(" #{i}: #{Automerge.list_get(node, alice_doc3, ["items"], i)}")
end
IO.puts("\nBob's list:")
for i <- 0..(Automerge.list_length(node, bob_doc3, ["items"]) - 1) do
IO.puts(" #{i}: #{Automerge.list_get(node, bob_doc3, ["items"], i)}")
end
# Merge
{:ok, alice_bytes3} = Automerge.save(node, alice_doc3)
{:ok, bob_bytes3} = Automerge.save(node, bob_doc3)
:ok = Automerge.merge(node, doc3, alice_bytes3)
:ok = Automerge.merge(node, doc3, bob_bytes3)
IO.puts("\nMerged list (both users' items preserved):")
for i <- 0..(Automerge.list_length(node, doc3, ["items"]) - 1) do
IO.puts(" #{i}: #{Automerge.list_get(node, doc3, ["items"], i)}")
end
Scenario 4: Concurrent Counter Updates
Counters always converge correctly, summing all increments.
# Create document with counter
doc4 = Automerge.new(node)
_ = Automerge.counter_increment(node, doc4, [], "score", 10)
IO.puts("Initial score: #{Automerge.counter_get(node, doc4, [], "score")}")
# Fork
alice_doc4 = Automerge.fork(node, doc4)
bob_doc4 = Automerge.fork(node, doc4)
charlie_doc4 = Automerge.fork(node, doc4)
# Each user increments concurrently
_ = Automerge.counter_increment(node, alice_doc4, [], "score", 5)
_ = Automerge.counter_increment(node, bob_doc4, [], "score", 3)
_ = Automerge.counter_increment(node, charlie_doc4, [], "score", 7)
IO.puts("\nAfter concurrent increments:")
IO.puts(" Alice (+5): #{Automerge.counter_get(node, alice_doc4, [], "score")}")
IO.puts(" Bob (+3): #{Automerge.counter_get(node, bob_doc4, [], "score")}")
IO.puts(" Charlie (+7): #{Automerge.counter_get(node, charlie_doc4, [], "score")}")
# Merge all
{:ok, alice_bytes4} = Automerge.save(node, alice_doc4)
{:ok, bob_bytes4} = Automerge.save(node, bob_doc4)
{:ok, charlie_bytes4} = Automerge.save(node, charlie_doc4)
:ok = Automerge.merge(node, doc4, alice_bytes4)
:ok = Automerge.merge(node, doc4, bob_bytes4)
:ok = Automerge.merge(node, doc4, charlie_bytes4)
final_score = Automerge.counter_get(node, doc4, [], "score")
IO.puts("\nFinal merged score: #{final_score}")
IO.puts("Expected: 10 (initial) + 5 (Alice) + 3 (Bob) + 7 (Charlie) = 25")
Scenario 5: Concurrent Text Edits
Text CRDTs handle concurrent character-level edits.
# Create document with text
doc5 = Automerge.new(node)
{:ok, _} = Automerge.create_text(node, doc5, [], "content", "Hello World")
IO.puts("Initial text: #{Automerge.text_get(node, doc5, ["content"])}")
# Fork
alice_doc5 = Automerge.fork(node, doc5)
bob_doc5 = Automerge.fork(node, doc5)
# Alice inserts at the beginning
:ok = Automerge.text_insert(node, alice_doc5, ["content"], 0, ">> ")
# Bob appends at the end
bob_text = Automerge.text_get(node, bob_doc5, ["content"])
:ok = Automerge.text_insert(node, bob_doc5, ["content"], String.length(bob_text), " <<")
IO.puts("\nAlice's text: #{Automerge.text_get(node, alice_doc5, ["content"])}")
IO.puts("Bob's text: #{Automerge.text_get(node, bob_doc5, ["content"])}")
# Merge
{:ok, alice_bytes5} = Automerge.save(node, alice_doc5)
{:ok, bob_bytes5} = Automerge.save(node, bob_doc5)
:ok = Automerge.merge(node, doc5, alice_bytes5)
:ok = Automerge.merge(node, doc5, bob_bytes5)
IO.puts("\nMerged text: #{Automerge.text_get(node, doc5, ["content"])}")
Understanding Conflict Resolution
Kino.Markdown.new("""
## How Automerge Resolves Conflicts
| Data Type | Concurrent Edit | Resolution Strategy |
|-----------|-----------------|---------------------|
| **Map (different keys)** | Alice sets A, Bob sets B | Both preserved |
| **Map (same key)** | Alice sets X=1, Bob sets X=2 | Last-writer-wins (by actor ID) |
| **List** | Alice appends A, Bob appends B | Both items interleaved |
| **Text** | Alice inserts at pos 5, Bob at pos 10 | Both insertions preserved |
| **Counter** | Alice +5, Bob +3 | Sum: +8 |
### Key Properties
1. **Deterministic**: Given the same set of changes, all nodes reach the same state
2. **Commutative**: Order of merging doesn't matter
3. **Idempotent**: Merging the same changes twice has no additional effect
### Best Practices
1. Use **counters** for values that should aggregate (votes, counts)
2. Use **lists** for ordered collections where concurrent additions are OK
3. Use **maps** with unique keys when possible to avoid same-key conflicts
4. Design your data model to minimize scalar conflicts on the same key
""")
Interactive Conflict Demo
# Create interactive demo
conflict_form =
Kino.Control.form(
[
alice_value: Kino.Input.text("Alice's value", default: "Alice's data"),
bob_value: Kino.Input.text("Bob's value", default: "Bob's data"),
key: Kino.Input.text("Key to set", default: "shared_field")
],
submit: "Create Conflict & Merge"
)
Kino.render(conflict_form)
result_frame = Kino.Frame.new()
Kino.render(result_frame)
Kino.listen(conflict_form, fn event ->
%{data: %{alice_value: alice_val, bob_value: bob_val, key: key}} = event
# Create fresh documents
base = Automerge.new(node)
:ok = Automerge.put(node, base, [], key, "original")
alice = Automerge.fork(node, base)
bob = Automerge.fork(node, base)
:ok = Automerge.put(node, alice, [], key, alice_val)
:ok = Automerge.put(node, bob, [], key, bob_val)
{:ok, alice_b} = Automerge.save(node, alice)
{:ok, bob_b} = Automerge.save(node, bob)
:ok = Automerge.merge(node, base, alice_b)
:ok = Automerge.merge(node, base, bob_b)
final = Automerge.get(node, base, [], key)
result =
Kino.Markdown.new("""
### Conflict Resolution Result
- **Key**: `#{key}`
- **Alice set**: `#{alice_val}`
- **Bob set**: `#{bob_val}`
- **Winner**: `#{final}`
```json
#{Automerge.to_json(node, base)}
```
""")
Kino.Frame.render(result_frame, result)
end)
Summary
This livebook demonstrated how Automerge handles various conflict scenarios:
- Different keys - All changes preserved
- Same key (scalar) - Deterministic last-writer-wins
- Lists - Concurrent items interleaved
- Counters - Values correctly summed
- Text - Character-level merging
Automerge's CRDT approach ensures that all nodes eventually converge to the same state, regardless of the order in which they receive updates.