<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>WebRTC on jairrs</title><link>https://jairsantana.com/tags/webrtc/</link><description>Recent content in WebRTC on jairrs</description><generator>Hugo -- 0.156.0</generator><language>en-us</language><atom:link href="https://jairsantana.com/tags/webrtc/index.xml" rel="self" type="application/rss+xml"/><item><title>YGo: Peer-to-Peer Collaborative Editing</title><link>https://jairsantana.com/portfolio/ygo/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://jairsantana.com/portfolio/ygo/</guid><description>A YATA text CRDT, WebRTC editing, and a Raft-backed Go signaling service.</description><content:encoded><![CDATA[<p>YGo is a collaborative editing app that uses near real-time peer-to-peer shared editing via CRDTs and a Go backend. We built it as a final project for MIT&rsquo;s 6.5840 Distributed Systems.</p>
<p>The primary goal of YGo was to achieve availability: it is disruptive for a user to be unable to create and edit documents because all resources are currently exhausted. We designed the signaling layer around near-stateless servers, allowing for quick horizontal scaling in case of need. By “near-stateless” we mean that some state is stored in each signaling server&rsquo;s memory, but losing the peer registry does not itself erase the clients&rsquo; document state. Established peer connections can continue without signaling; connecting or reconnecting peers depends on recovering the room assignment.</p>
<p>A second goal was to achieve fault tolerance: if one of the signaling servers dies, clients should still be able to create documents and establish P2P connections, provided the backend retains a Raft quorum. Since the servers relay signaling traffic between clients, we designed the system so that every client within the same document connects to the same signaling server. Without carefully addressing the failure of a hosting server, there could be split brain in the client network, causing two or more groups of clients to be unable to communicate with each other.</p>
<h2 id="contributions">Contributions</h2>
<p>I built YGo with Carolina Moura, with whom I coauthored the final report. I worked on the client described in Section III: the YATA list and text CRDT, its interface, and the peer-to-peer synchronization protocol. The Raft and key-value server code also came from my 6.5840 labs. For this project, we adapted the storage system to use gRPC instead of the simulated RPC system provided for testing.</p>
<h2 id="system-overview">System Overview</h2>
<p>A client can query any YGo node on its assigned HTTP port to create a document, referred to as a “room,” or look up its hosting signaling server. The client then opens a WebSocket to that host and receives the list of current collaborators. Via the WebSocket, clients exchange the information needed to establish WebRTC connections within the same document. Document edits travel over WebRTC data channels rather than through the Go server.</p>
<p>On startup, each backend node starts an HTTP server, a gRPC server, a key-value instance, and a Raft instance. Upon client requests, the HTTP server calls functions provided by the Clerk, which calls the KV service over the network. The replicated state machine under the KV server uses Raft as its consensus algorithm. Raft and KV share the gRPC listener and the client connections between nodes.</p>
<figure>
    <img loading="lazy" src="/images/ygo/backend.png"
         alt="Report diagram of one YGo node: HTTP on port 8080 calls the Clerk; gRPC on port 7001 exposes KV and Raft, with the KV state machine submitting operations through the RSM"/> <figcaption>
            <p>Figure 1 from our final report: one backend node. HTTP and WebSocket traffic use port 8080; KV and Raft RPCs share port 7001. The browser-side CRDT is outside this backend diagram.</p>
        </figcaption>
</figure>

<p>The distinction between document state and room metadata is central to the design. The replicated backend stores room assignments and signaling-server heartbeats. The document itself lives in the peers&rsquo; CRDT instances. Replicating metadata therefore does not make document content durable.</p>
<h2 id="the-yata-list-and-text-crdt">The YATA List and Text CRDT</h2>
<p>We employed the YATA approach to a Conflict-free Replicated Data Type, whose basic primitive is the YATA list. Peers can operate concurrently on different versions of the list and share updates without requiring consensus amongst themselves. Convergence depends on integrating the same updates using deterministic ordering rules.</p>
<p>A list item stores its identifier, creation-time origins, mutable linked-list neighbors, content, and a deletion flag:</p>
<span class="katex"><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mrow><mi>i</mi><mo>=</mo><mo stretchy="false">⟨</mo><mi>I</mi><mi>D</mi><mo separator="true">,</mo><mrow><mi mathvariant="normal">o</mi><mi mathvariant="normal">r</mi><mi mathvariant="normal">i</mi><mi mathvariant="normal">g</mi><mi mathvariant="normal">i</mi><mi mathvariant="normal">n</mi><mi mathvariant="normal">L</mi><mi mathvariant="normal">e</mi><mi mathvariant="normal">f</mi><mi mathvariant="normal">t</mi></mrow><mo separator="true">,</mo><mrow><mi mathvariant="normal">o</mi><mi mathvariant="normal">r</mi><mi mathvariant="normal">i</mi><mi mathvariant="normal">g</mi><mi mathvariant="normal">i</mi><mi mathvariant="normal">n</mi><mi mathvariant="normal">R</mi><mi mathvariant="normal">i</mi><mi mathvariant="normal">g</mi><mi mathvariant="normal">h</mi><mi mathvariant="normal">t</mi></mrow><mo separator="true">,</mo><mrow><mi mathvariant="normal">l</mi><mi mathvariant="normal">e</mi><mi mathvariant="normal">f</mi><mi mathvariant="normal">t</mi></mrow><mo separator="true">,</mo><mrow><mi mathvariant="normal">r</mi><mi mathvariant="normal">i</mi><mi mathvariant="normal">g</mi><mi mathvariant="normal">h</mi><mi mathvariant="normal">t</mi></mrow><mo separator="true">,</mo><mrow><mi mathvariant="normal">c</mi><mi mathvariant="normal">o</mi><mi mathvariant="normal">n</mi><mi mathvariant="normal">t</mi><mi mathvariant="normal">e</mi><mi mathvariant="normal">n</mi><mi mathvariant="normal">t</mi></mrow><mo separator="true">,</mo><mrow><mi mathvariant="normal">d</mi><mi mathvariant="normal">e</mi><mi mathvariant="normal">l</mi><mi mathvariant="normal">e</mi><mi mathvariant="normal">t</mi><mi mathvariant="normal">e</mi><mi mathvariant="normal">d</mi></mrow><mo stretchy="false">⟩</mo><mi mathvariant="normal">.</mi></mrow><annotation encoding="application/x-tex">
i = \langle ID,\mathrm{originLeft},\mathrm{originRight},\mathrm{left},\mathrm{right},\mathrm{content},\mathrm{deleted}\rangle.
</annotation></semantics></math></span><p>The origins refer to the IDs of the items immediately to the left and right of <span class="katex"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>i</mi></mrow><annotation encoding="application/x-tex">i</annotation></semantics></math></span> when it was first created. The <code>left</code> and <code>right</code> fields are mutable pointers used to maintain the local view of the linked list. A boolean flag, or tombstone, is used for deletions to preserve the structure: an item is marked deleted rather than removed, so concurrent insertions referencing its ID as an origin can still be resolved.</p>
<p>Each item is assigned an ID consisting of a pair:</p>
<span class="katex"><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mrow><mi>I</mi><mi>D</mi><mo>=</mo><mo stretchy="false">(</mo><mi>f</mi><mo separator="true">,</mo><mi>c</mi><mo stretchy="false">)</mo><mo separator="true">,</mo></mrow><annotation encoding="application/x-tex">
ID = (f,c),
</annotation></semantics></math></span><p>where <span class="katex"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>f</mi></mrow><annotation encoding="application/x-tex">f</annotation></semantics></math></span> identifies the client and <span class="katex"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>c</mi></mrow><annotation encoding="application/x-tex">c</annotation></semantics></math></span> is a monotonically increasing insertion counter maintained by that client. The report described <span class="katex"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>f</mi></mrow><annotation encoding="application/x-tex">f</annotation></semantics></math></span> as a UUID. In the supplied implementation, the browser hashes the signaling UUID with SHA-256 and uses the first signed 32-bit word as its numeric CRDT identifier. This is an identifier scheme rather than a guarantee of uniqueness; collisions remain possible.</p>
<h3 id="deterministic-insertion">Deterministic Insertion</h3>
<p>When integrating item <span class="katex"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>i</mi></mrow><annotation encoding="application/x-tex">i</annotation></semantics></math></span>, the local list may already contain other insertions between <code>originLeft</code> and <code>originRight</code>, made after <span class="katex"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>i</mi></mrow><annotation encoding="application/x-tex">i</annotation></semantics></math></span> was created. To find its position deterministically, the algorithm traverses the local list from the left origin&rsquo;s right neighbor toward the right origin.</p>
<p>For items sharing the same left origin, it breaks ties by client identifier. An item with a smaller client identifier is resolved to the left of the incoming item. If it has the same right origin and does not sort to the left, the scan stops. The algorithm also tracks items encountered during the scan and unresolved conflicting items, so insertions whose origins lie within that region retain their relationship to preceding items. An item with a left origin outside the scanned region ends the scan. The new item is then linked immediately after the rightmost item resolved as being to its left.</p>
<p>The report defined a counter-first total order over IDs, but the insertion code uses client identifiers for the same-origin tie-break. The excerpt below shows that implemented rule and the dependency tracking directly.</p>
<details class="article-details">
    <summary>Code: deterministic insertion scan</summary>
    <div class="article-details-content">
        <p>From <code>crdt/list.ts</code>; excerpt from the supplied source.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span>    <span style="color:#ff7b72">while</span> (<span style="color:#ff7b72;font-weight:bold">!</span>ID.equals(scan.id, right.id)) {
</span></span><span style="display:flex;"><span>      itemsBeforeOrigin.add(ID.key(scan.id));
</span></span><span style="display:flex;"><span>      conflictingItems.add(ID.key(scan.id));
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      <span style="color:#ff7b72">if</span> (ID.equals(scan.originLeft, item.originLeft)) {
</span></span><span style="display:flex;"><span>        <span style="color:#8b949e;font-style:italic">// tie-break by UUID
</span></span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">if</span> (scan.id.userID <span style="color:#ff7b72;font-weight:bold">&lt;</span> item.id.userID) {
</span></span><span style="display:flex;"><span>          left <span style="color:#ff7b72;font-weight:bold">=</span> scan;
</span></span><span style="display:flex;"><span>          conflictingItems.clear();
</span></span><span style="display:flex;"><span>        } <span style="color:#ff7b72">else</span> <span style="color:#ff7b72">if</span> (ID.equals(scan.originRight, item.originRight)) {
</span></span><span style="display:flex;"><span>          <span style="color:#ff7b72">break</span>;
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>      } <span style="color:#ff7b72">else</span> <span style="color:#ff7b72">if</span> (itemsBeforeOrigin.has(ID.key(scan.originLeft))) {
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">if</span> (<span style="color:#ff7b72;font-weight:bold">!</span>conflictingItems.has(ID.key(scan.originLeft))) {
</span></span><span style="display:flex;"><span>          left <span style="color:#ff7b72;font-weight:bold">=</span> scan;
</span></span><span style="display:flex;"><span>          conflictingItems.clear();
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>      } <span style="color:#ff7b72">else</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#ff7b72">break</span>;
</span></span><span style="display:flex;"><span>      }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>      scan <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">this</span>.getItem(scan.right);
</span></span><span style="display:flex;"><span>    }
</span></span></code></pre></div>
    </div>
</details>

<p>When inserting multiple items at the same time, they may not be in dependency order. <code>applyUpdate</code> repeatedly integrates items whose origins are already known. If it drains the pending set, integration is complete; if a pass makes no progress and pending items remain, it throws an unresolved-origins error. For an item already present, it merges deletion state monotonically: an incoming tombstone can mark the existing item deleted, but cannot restore it.</p>
<p>We implemented a simple Text CRDT using the YATA list by making each insertion an individual character. Allowing arbitrary-length strings to be inserted as a single item, and splitting them only when required by later operations, remains a potential optimization.</p>
<h3 id="state-vectors-and-deletions">State Vectors and Deletions</h3>
<p>A state vector records how many insertions from each client the peer knows. In the source, the stored value is the next counter after the greatest integrated counter:</p>
<span class="katex"><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mrow><mi>S</mi><mi>V</mi><mo stretchy="false">[</mo><mi>f</mi><mo stretchy="false">]</mo><mo>=</mo><munder><mrow><mi>max</mi><mo>⁡</mo></mrow><mrow><mtext>integrated items from </mtext><mi>f</mi></mrow></munder><mo stretchy="false">(</mo><mi>c</mi><mo>+</mo><mn>1</mn><mo stretchy="false">)</mo><mo separator="true">,</mo></mrow><annotation encoding="application/x-tex">
SV[f] = \max_{\text{integrated items from }f}(c+1),
</annotation></semantics></math></span><p>with zero used when none are known. <code>makeUpdate</code> sends items for which <span class="katex"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>c</mi><mo>≥</mo><mi>S</mi><msub><mi>V</mi><mrow><mi mathvariant="normal">r</mi><mi mathvariant="normal">e</mi><mi mathvariant="normal">c</mi><mi mathvariant="normal">i</mi><mi mathvariant="normal">p</mi><mi mathvariant="normal">i</mi><mi mathvariant="normal">e</mi><mi mathvariant="normal">n</mi><mi mathvariant="normal">t</mi></mrow></msub><mo stretchy="false">[</mo><mi>f</mi><mo stretchy="false">]</mo></mrow><annotation encoding="application/x-tex">c \geq SV_{\mathrm{recipient}}[f]</annotation></semantics></math></span>. This representation assumes that the counter summarizes a complete prefix; it does not encode arbitrary gaps in a client&rsquo;s insertion history.</p>
<details class="article-details">
    <summary>Code: selecting items missing from a state vector</summary>
    <div class="article-details-content">
        <p>From <code>crdt/list.ts</code>; excerpt from the supplied source.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span>    <span style="color:#ff7b72">const</span> items: <span style="color:#ff7b72">YataItem</span>[] <span style="color:#ff7b72;font-weight:bold">=</span> [];
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">for</span> (<span style="color:#ff7b72">const</span> item <span style="color:#ff7b72">of</span> <span style="color:#ff7b72">this</span>.itemById.values()) {
</span></span><span style="display:flex;"><span>      <span style="color:#ff7b72">if</span> (<span style="color:#ff7b72">this</span>.isSentinel(item)) <span style="color:#ff7b72">continue</span>;
</span></span><span style="display:flex;"><span>      <span style="color:#ff7b72">const</span> known <span style="color:#ff7b72;font-weight:bold">=</span> target.<span style="color:#ff7b72">get</span>(item.id.userID) <span style="color:#ff7b72;font-weight:bold">??</span> <span style="color:#a5d6ff">0</span>;
</span></span><span style="display:flex;"><span>      <span style="color:#ff7b72">if</span> (item.id.opCounter <span style="color:#ff7b72;font-weight:bold">&gt;=</span> known) {
</span></span><span style="display:flex;"><span>        items.push(item);
</span></span><span style="display:flex;"><span>      }
</span></span><span style="display:flex;"><span>    }
</span></span></code></pre></div>
    </div>
</details>

<p>Deletions do not allocate new insertion IDs. The protocol therefore sends tombstone keys separately from the insertion diff. <code>getItems</code> returns a left-to-right ordered list of items that have not been deleted, while the underlying tombstoned items remain available for resolving origins.</p>
<h2 id="peer-to-peer-synchronization">Peer-to-Peer Synchronization</h2>
<p>A group of peers sharing the same text instance communicates edits through <code>TextPeer</code>, a protocol layer that wraps the CRDT. The wire format has two message kinds: <code>stateVector</code>, carrying the sender&rsquo;s full state vector, and <code>update</code>, carrying CRDT items and tombstone keys.</p>
<h3 id="initial-sync">Initial Sync</h3>
<p>When a peer&rsquo;s data channel to another peer first opens, it calls <code>syncWithPeer</code>, which sends a state-vector message. The receiver replies with an update containing every item the sender is missing, computed with <code>makeUpdate</code>, plus every tombstoned key. Because the data channel opens on both sides, the exchange runs symmetrically so each peer can acquire the other&rsquo;s state at the time of synchronization.</p>
<h3 id="periodic-broadcast">Periodic Broadcast</h3>
<p>After initial sync, a timer in the application layer calls <code>broadcastChanges</code>. Each call builds an item diff against <code>lastBroadcastSV</code> and a deletion list from tombstones appended since the previous broadcast. If either list is nonempty, it sends the update to every connected peer and advances both broadcast cursors.</p>
<details class="article-details">
    <summary>Code: batched update broadcasts</summary>
    <div class="article-details-content">
        <p>From <code>web/src/lib/textPeer.ts</code>; excerpt from the supplied source.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span>  broadcastChanges(rtc: <span style="color:#ff7b72">RTC</span>)<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#ff7b72">void</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">const</span> items <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">this</span>.text.makeUpdate(<span style="color:#ff7b72">this</span>.lastBroadcastSV);
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">const</span> deletes <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">this</span>.tombstoneOrder.slice(
</span></span><span style="display:flex;"><span>      <span style="color:#ff7b72">this</span>.lastBroadcastTombstoneCount,
</span></span><span style="display:flex;"><span>    );
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">if</span> (items.length <span style="color:#ff7b72;font-weight:bold">===</span> <span style="color:#a5d6ff">0</span> <span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span> deletes.length <span style="color:#ff7b72;font-weight:bold">===</span> <span style="color:#a5d6ff">0</span>) <span style="color:#ff7b72">return</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">const</span> msg: <span style="color:#ff7b72">WireMessage</span> <span style="color:#ff7b72;font-weight:bold">=</span> { kind<span style="color:#ff7b72;font-weight:bold">:</span> <span style="color:#a5d6ff">&#34;update&#34;</span>, items, deletes };
</span></span><span style="display:flex;"><span>    rtc.broadcast(JSON.stringify(msg));
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">this</span>.lastBroadcastSV <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">this</span>.text.getStateVector();
</span></span><span style="display:flex;"><span>    <span style="color:#ff7b72">this</span>.lastBroadcastTombstoneCount <span style="color:#ff7b72;font-weight:bold">=</span> <span style="color:#ff7b72">this</span>.tombstoneOrder.length;
</span></span><span style="display:flex;"><span>    console.log(<span style="color:#a5d6ff">&#34;broadcastChanges&#34;</span>, JSON.stringify(msg));
</span></span><span style="display:flex;"><span>  }
</span></span></code></pre></div>
    </div>
</details>

<p><code>handleMessage</code> applies incoming items and tombstones. In the supplied source, it leaves the broadcast cursors unchanged, allowing items and tombstones learned from one neighbor to ride out on the next broadcast to other neighbors. The cursor-advancing optimization in that method is commented out because it would suppress forwarding in a partially connected graph.</p>
<p>A useful property of this design is that the graph of peers need not be fully connected for updates to propagate. The design uses forwarding to propagate updates along paths in a connected component, provided dependencies can be integrated and messages continue to be delivered. Disconnected components cannot exchange edits until a path is restored. The browser&rsquo;s connection setup nevertheless attempts a mesh by connecting each new peer to the peers listed in its welcome message.</p>
<p>The supplied UI defaults to a two-second broadcast interval, adjustable from 200 ms to 10 seconds in 200 ms steps. These are batching settings, not measured end-to-end editing latencies. The local text changes immediately; remote visibility depends on the next broadcast, transport, and integration.</p>
<h3 id="why-broadcasting">Why broadcasting?</h3>
<p>It&rsquo;s possible to implement the peer-to-peer protocol as a pull-based protocol instead of a push-based protocol. This would entail deciding when to request updates instead of when to send them to peers. I decided to stick with a push-based protocol for several reasons. First, it is much simpler to think about and implement. Broadcasting peers need not wait for a reply and receiving peers don&rsquo;t have to send one. Second, it removes needless probing that would occur with a pull-based approach. Peers can be optimized to only broadcast when they have something to share, reducing traffic. A notification-push and pull-based approach could circumvent these kinds of issues, but there is very little gain in complicating the approach in this way.</p>
<h2 id="signaling-and-room-assignment">Signaling and Room Assignment</h2>
<p>The signaling server fulfills three purposes: it manages the creation of rooms, answers requests about which server currently hosts a room, and acts as a middleman in establishing P2P connections if it is the hosting server.</p>
<p>The storage interface provides versioned reads and conditional writes:</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>GET(k) → (value, version)
</span></span><span style="display:flex;"><span>PUT(k, v, version) → ok | ErrVersion | ErrMaybe
</span></span></code></pre></div><p>A successful write increments the version. <code>ErrVersion</code> indicates a conflicting version; <code>ErrMaybe</code> represents an uncertain outcome after a retry. This distinction matters because a failed response does not necessarily mean the original write was not applied.</p>
<p>The report described separate <code>server/&lt;addr&gt;</code> lease keys. The supplied source instead stores a serialized address-to-timestamp map under one <code>heartbeats</code> key. Each server updates its entry with a versioned read-modify-write. Room ownership is stored separately as <code>room/&lt;id&gt; → advertised address</code>.</p>
<h3 id="heartbeats-and-room-creation">Heartbeats and Room Creation</h3>
<p>Each server registers its advertised address and periodically renews its timestamp. With the supplied startup configuration, renewals are scheduled every 10 seconds and <code>Signalings</code> uses a 30-second freshness window. A server belongs to the live set when</p>
<span class="katex"><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mrow><msub><mi>t</mi><mrow><mi mathvariant="normal">l</mi><mi mathvariant="normal">o</mi><mi mathvariant="normal">c</mi><mi mathvariant="normal">a</mi><mi mathvariant="normal">l</mi></mrow></msub><mo>−</mo><msub><mi>t</mi><mrow><mi mathvariant="normal">h</mi><mi mathvariant="normal">e</mi><mi mathvariant="normal">a</mi><mi mathvariant="normal">r</mi><mi mathvariant="normal">t</mi><mi mathvariant="normal">b</mi><mi mathvariant="normal">e</mi><mi mathvariant="normal">a</mi><mi mathvariant="normal">t</mi></mrow></msub><mo>≤</mo><mn>30,000</mn><mtext> </mtext><mrow><mi mathvariant="normal">m</mi><mi mathvariant="normal">s</mi></mrow><mi mathvariant="normal">.</mi></mrow><annotation encoding="application/x-tex">
t_{\mathrm{local}} - t_{\mathrm{heartbeat}} \leq 30{,}000\ \mathrm{ms}.
</annotation></semantics></math></span><p>These values describe the implementation&rsquo;s timers; they are not a bound on failover time. Divergent wall clocks, failed renewals, retries, and delayed requests all affect detection.</p>
<p>On <code>POST /api/rooms</code>, the server lists the live signaling set, picks a host uniformly at random, generates a fresh six-character room ID, and attempts a version-zero write. The report called these IDs alphanumeric; the current generator uses uppercase letters only. The receiving server need not be the chosen host: any signaling server can create a room on behalf of another. The current creation loop generates a new ID after any non-OK result, rather than distinguishing only version conflicts.</p>
<h3 id="lookup-and-reassignment">Lookup and Reassignment</h3>
<p>On <code>GET /api/rooms/:room</code>, the server reads the room assignment and checks whether the recorded host is still in the live set. If it is, its address is returned. If its heartbeat has expired, the server picks a new host and writes it with the version observed during lookup. If another lookup raced this update, <code>ErrVersion</code> causes the check to repeat.</p>
<details class="article-details">
    <summary>Code: conditional reassignment after a host expires</summary>
    <div class="article-details-content">
        <p>From <code>server.go</code>; excerpt from the supplied source.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6e7681">		</span><span style="color:#8b949e;font-style:italic">// The server is dead, pick a new random server</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">		</span>newServer<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">:=</span><span style="color:#6e7681"> </span>live[rand.<span style="color:#d2a8ff;font-weight:bold">Intn</span>(len(live))]<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">		</span>putErr<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">:=</span><span style="color:#6e7681"> </span>s.ck.<span style="color:#d2a8ff;font-weight:bold">Put</span>(<span style="color:#a5d6ff">&#34;room/&#34;</span><span style="color:#ff7b72;font-weight:bold">+</span>room,<span style="color:#6e7681"> </span>newServer,<span style="color:#6e7681"> </span>version)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">		</span><span style="color:#ff7b72">if</span><span style="color:#6e7681"> </span>putErr<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">==</span><span style="color:#6e7681"> </span>rpc.OK<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">			</span>fmt.<span style="color:#d2a8ff;font-weight:bold">Printf</span>(<span style="color:#a5d6ff">&#34;Room %q reassigned: %q (dead) -&gt; %q\n&#34;</span>,<span style="color:#6e7681"> </span>room,<span style="color:#6e7681"> </span>server,<span style="color:#6e7681"> </span>newServer)<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">			</span><span style="color:#ff7b72">return</span><span style="color:#6e7681"> </span>newServer,<span style="color:#6e7681"> </span>rpc.OK<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">		</span>}<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">		</span><span style="color:#ff7b72">if</span><span style="color:#6e7681"> </span>putErr<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">==</span><span style="color:#6e7681"> </span>rpc.ErrVersion<span style="color:#6e7681"> </span>{<span style="color:#6e7681"> </span><span style="color:#8b949e;font-style:italic">// someone raced us; prob will get out in the next iteration</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">			</span><span style="color:#ff7b72">continue</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">		</span>}<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">		</span><span style="color:#ff7b72">return</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">&#34;&#34;</span>,<span style="color:#6e7681"> </span>putErr<span style="color:#6e7681">
</span></span></span></code></pre></div>
    </div>
</details>

<p>The host can die between the lookup and the client&rsquo;s connection attempt. The client discovers this when connecting fails and repeats the lookup. In the supplied frontend, reconnection starts with a 500 ms backoff, doubles on repeated failures, and caps at 10 seconds. Lookup tries the page&rsquo;s origin and a hard-coded list of three localhost servers, which would need to be replaced for deployment.</p>
<h3 id="establishing-peer-connections">Establishing Peer Connections</h3>
<p>Before upgrading a WebSocket, the server validates the room and checks that it is still the assigned host. On a successful upgrade, it assigns a fresh peer UUID, inserts the connection into its in-memory room registry, and sends a welcome message carrying that ID and the IDs of current peers.</p>
<p>Peers use signaling to exchange SDP offers, answers, and ICE candidates. The report described all peer JSON as broadcast; the source stamps the sender&rsquo;s ID and unicasts messages with a <code>to</code> field to that peer. Messages without a target are broadcast to the other peers in the room. Once WebRTC data channels are established, edits use those channels.</p>
<h3 id="recovering-from-signaling-failure">Recovering from Signaling Failure</h3>
<p>We addressed two kinds of failure. When the hosting signaling server dies, its heartbeat eventually expires. A subsequent lookup through a surviving node reassigns the room. Clients notice the WebSocket close and repeat the lookup, reconnecting to the new host. The frontend keeps its RTC instance and CRDT across the signaling outage, so the outage alone does not tear down established data channels or discard document state. Recovery behavior also depends on subsequent peer-membership messages and reconnection.</p>
<p>A server can also be reassigned away from a room while still alive if it misses enough heartbeats. It may still hold open WebSockets for rooms it no longer owns. To address this stale registry, each server runs an eviction check every 10 seconds. If it discovers it is no longer the assigned host, it closes the room&rsquo;s WebSockets, triggering client lookup and reconnection. This is a periodic recovery mechanism, not an instantaneous fencing guarantee.</p>
<h2 id="replicated-key-value-storage">Replicated Key-Value Storage</h2>
<p>For the storage system, we used the code from Lab 4 and adapted it to gRPC. We also used the lab&rsquo;s Raft code beneath the replicated state machine that sustains the KV server. Both <code>Get</code> and <code>Put</code> handlers submit operations through the RSM; the Clerk retries against other nodes when it receives <code>ErrWrongLeader</code> or cannot reach a server.</p>
<p>The KV state machine accepts a write only when its expected version matches the current value. For a new key, the expected version must be zero.</p>
<details class="article-details">
    <summary>Code: version checks in the KV state machine</summary>
    <div class="article-details-content">
        <p>From <code>kv/kv.go</code>; excerpt from the supplied source.</p>
<div class="highlight"><pre tabindex="0" style="color:#e6edf3;background-color:#0d1117;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#6e7681">	</span>vv,<span style="color:#6e7681"> </span>ok<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">:=</span><span style="color:#6e7681"> </span>kv.store[args.Key]<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">	</span><span style="color:#ff7b72">if</span><span style="color:#6e7681"> </span>!ok<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span><span style="color:#6e7681"> </span>args.Version<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">!=</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">0</span><span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">		</span>reply.Err<span style="color:#6e7681"> </span>=<span style="color:#6e7681"> </span>rpc.ErrNoKey<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">		</span><span style="color:#ff7b72">return</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">	</span>}<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">	</span><span style="color:#ff7b72">if</span><span style="color:#6e7681"> </span>ok<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">&amp;&amp;</span><span style="color:#6e7681"> </span>vv.Version<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">!=</span><span style="color:#6e7681"> </span>args.Version<span style="color:#6e7681"> </span>{<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">		</span>reply.Err<span style="color:#6e7681"> </span>=<span style="color:#6e7681"> </span>rpc.ErrVersion<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">		</span><span style="color:#ff7b72">return</span><span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">	</span>}<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">	</span>kv.store[args.Key]<span style="color:#6e7681"> </span>=<span style="color:#6e7681"> </span>VersionedValue{Value:<span style="color:#6e7681"> </span>args.Value,<span style="color:#6e7681"> </span>Version:<span style="color:#6e7681"> </span>args.Version<span style="color:#6e7681"> </span><span style="color:#ff7b72;font-weight:bold">+</span><span style="color:#6e7681"> </span><span style="color:#a5d6ff">1</span>}<span style="color:#6e7681">
</span></span></span><span style="display:flex;"><span><span style="color:#6e7681">	</span>reply.Err<span style="color:#6e7681"> </span>=<span style="color:#6e7681"> </span>rpc.OK<span style="color:#6e7681">
</span></span></span></code></pre></div>
    </div>
</details>

<p>The source uses a bbolt-backed persister for Raft state and snapshot bytes. This storage supports the replicated metadata layer; it does not store the browser&rsquo;s text CRDT. KV snapshot and restore methods exist, while <code>bootRaft</code> passes <code>-1</code> as the RSM&rsquo;s snapshot threshold, disabling automatic size-triggered snapshots in this configuration.</p>
<p>The report assumed an always-available, strongly consistent storage service when explaining signaling. In practice, committing these operations requires a Raft majority. The supplied Compose configuration contains three nodes, so two are required for a quorum. Adding signaling capacity does not itself provide automatic Raft membership changes. Existing peer editing can continue independently of the backend, but room creation, lookup, and reassignment depend on the metadata service making progress.</p>
<h2 id="results-and-limitations">Results and Limitations</h2>
<p>We implemented the browser-side YATA list and character-based text wrapper, initial state exchange, periodic edit broadcasts, WebRTC signaling, versioned room assignment, heartbeat-based discovery, and periodic eviction of stale room registries. The final report described the architecture and recovery paths but did not include quantitative latency, throughput, scalability, or failover measurements. The timer values above are source configuration, and the recovery paths describe mechanisms rather than experimentally established guarantees.</p>
<p>The project has several limitations:</p>
<ul>
<li><strong>Document durability.</strong> Content is not saved to persistent storage. Once the last peer leaves a room, the document content is lost even if its room assignment remains in the database.</li>
<li><strong>Resource exhaustion.</strong> There is no authentication or rate limiting to prevent a user from creating large numbers of rooms. Room records accumulate in the metadata store; retained items and tombstones also grow with edit history.</li>
<li><strong>Wall-clock leases.</strong> Heartbeat freshness relies on each machine&rsquo;s clock. Clock differences can cause a node to be declared dead prematurely, and storing all heartbeats in one map introduces contention on that key.</li>
<li><strong>Editor scope.</strong> The supplied UI handles single-character insertion, line breaks, and single-character deletion. It rejects range edits, paste, IME composition, and other multi-character inputs.</li>
<li><strong>Update handling.</strong> Unknown tombstone keys are ignored, and unresolved insertion origins cause an exception rather than entering a persistent retry queue. These paths limit the implementation&rsquo;s tolerance of missing dependencies; connected topology alone is not an unconditional delivery or convergence guarantee.</li>
</ul>
<p>Planned work included a <code>Sync</code> operation to store CRDT state in the database, client snapshots for document persistence, authentication, rate limiting, a load balancer, and a more reliable lease implementation. Arbitrary-length text items remain another potential optimization. These features are not implemented in the supplied source.</p>
<h2 id="source-code-and-references">Source Code and References</h2>
<p><a href="https://github.com/CarolinaMoura/6.5840-final-project">Project source</a>.</p>
<p>The underlying Raft and KV code originated in my course labs. The report asks readers to obtain permission before accessing that lab repository.</p>
<p>The CRDT design followed P. Nicolaescu, K. Jahns, M. Derntl, and R. Klamma, <em>Near real-time peer-to-peer shared editing on extensible data types</em>, GROUP &lsquo;16, pp. 39–49 (<a href="https://doi.org/10.1145/2957276.2957310">DOI</a>).</p>
]]></content:encoded></item></channel></rss>