---ci--- project: atelier phase: 0 milestone: v0.4 status: complete requirements: covered: [ATELIER-92, ATELIER-93, ATELIER-94, ATELIER-95, ATELIER-96, ATELIER-97, ATELIER-98, ATELIER-99, ATELIER-100, ATELIER-101, ATELIER-102, ATELIER-103, ATELIER-104, ATELIER-105, ATELIER-106, ATELIER-107, ATELIER-108, ATELIER-109, ATELIER-110, ATELIER-111, ATELIER-112, ATELIER-113, ATELIER-114, ATELIER-115, ATELIER-116, ATELIER-117] partial: [] ---/ci---
6.9 KiB
Bad Example: Edge Sync Loop (Livelock)
A two-node sync that oscillates divergent state forever. This is the pre-specified
edge-sync-loopchaos anti-pattern (ATELIER-110). Single-breach per D-068: the principle breached is Edge P4 (Sync Conflicts are Bounded, Not Infinite). The prose explains why this is a livelock, not eventual consistency.
The Code
// Two edge nodes (a tablet and a dispatcher workstation) sync a
// work-order status. The merge uses wall-clock timestamps with no
// vector clock and no deterministic tiebreak. A clock skew flips
// the winner on every merge pass; the state oscillates forever.
interface WorkOrderState {
orderId: string;
status: string;
wallClock: number; // P4 VIOLATION: wall time, not monotonic
nodeId: string;
}
// P4 VIOLATION: the merge picks the later wall-clock write as the
// winner. Wall time skews across nodes; a skew of even a few
// milliseconds flips the winner. With no vector clock, concurrent
// writes are not detected; with no deterministic tiebreak, equal
// timestamps are resolved by whichever node's clock is ahead.
function mergeBad(local: WorkOrderState, remote: WorkOrderState): WorkOrderState {
// No vector clock. No monotonic logical clock. No deterministic
// tiebreak by nodeId. This is wall-clock-only LWW on durable
// state — the row in the CRDT-vs-LWW decision matrix that is a
// P4 violation for anything but ephemeral state.
if (local.wallClock >= remote.wallClock) return local;
return remote;
}
// The sync loop: each node merges the other's state, writes the
// result, and the next sync pass flips it back. The loop never
// terminates.
async function syncLoop(node: "tablet" | "dispatcher", peer: WorkOrderState) {
let local = store.get(peer.orderId);
while (true) {
const merged = mergeBad(local, peer);
store.set(merged.orderId, merged);
await pushToPeer(merged); // peer receives, merges, pushes back
peer = await pullFromPeer(); // peer's clock is now ahead — flips winner
local = store.get(peer.orderId); // local re-merges; flips again
// The loop runs forever. Convergence is never reached. This is
// a livelock: the system is making progress (each pass writes)
// but the state never converges.
}
}
The tablet sets status: "completed" at wall time 10:00:00.500.
The dispatcher's clock is 50ms ahead; it sets status: "reassigned"
at wall time 10:00:00.550. The first merge: dispatcher wins
(10:00:00.550 > 10:00:00.500). The tablet receives reassigned,
but its clock drifts ahead by 100ms during the next sync pass; it
writes completed at 10:00:00.650. The second merge: tablet
wins. The dispatcher's clock drifts ahead again; it writes
reassigned at 10:00:00.750. The third merge: dispatcher wins.
The state flips between completed and reassigned on every sync
pass. The loop runs forever.
Why It Violates
Sync Conflicts are Bounded, Not Infinite (Edge P4, C1, C5)
- The breach: the merge has no convergence bound. The CRDT-vs-
LWW decision matrix in
domains/edge/sync.mdis explicit: wall-clock-only LWW on durable state carries an unbounded failure mode — clock skew = oscillation — and is a P4 violation for anything but ephemeral state. This code is the matrix's failure column made real. - Why it is a livelock, not eventual consistency: eventual consistency guarantees that, in the absence of new writes, all replicas eventually converge. This system never converges even with no new writes: the clock skew alone drives the oscillation. Each sync pass writes (so the system is "busy"), but the state never settles — the definition of a livelock. The P4 contract is that the merge terminates and converges; this merge does neither.
- The missing pieces, per the decision matrix:
- No monotonic logical clock (a hybrid logical clock or vector clock) — wall time skews, and the skew is unbounded.
- No vector clock — concurrent writes are not detected, so the conflict is invisible; the merge silently flips instead of surfacing.
- No deterministic tiebreak (e.g.,
nodeId) on equal timestamps — equal wall times are resolved by whichever node's clock is ahead, which is not a stable property.
- The P4 bound requires that the merge function be associative, commutative, and idempotent, terminating in one pass. A wall-clock merge with clock skew satisfies none of these: it is not associative (order of merges flips the winner), not idempotent (a re-merge after a clock drift flips the result), and not terminating (the loop runs forever).
The Fix
// Fix: replace wall-clock LWW with a CRDT register (state-based
// CvRDT) using a monotonic logical clock and a deterministic
// tiebreak by nodeId. The merge is now a total order — no
// oscillation (P4 bounded). See the CRDT-vs-LWW decision matrix:
// for a register on an unreliable network, a state-based CRDT is
// the correct row.
interface LWWRegister<T> {
value: T;
timestamp: number; // monotonic logical clock (HLC), not wall time
nodeId: string; // deterministic tiebreak
}
function mergeLWWRegister<T>(
local: LWWRegister<T>,
remote: LWWRegister<T>,
): LWWRegister<T> {
// Associative, commutative, idempotent (P4 bound, P5 idempotent).
// (timestamp, nodeId) is a total order — convergence in one pass.
if (local.timestamp > remote.timestamp) return local;
if (local.timestamp < remote.timestamp) return remote;
return local.nodeId > remote.nodeId ? local : remote;
}
// The sync is now one pass: merge, write, done. No loop, no
// oscillation. A re-merge of the same two replicas yields the same
// result (P5 idempotent), so a retried sync is safe.
async function syncOnce(local: LWWRegister<unknown>, remote: LWWRegister<unknown>) {
const converged = mergeLWWRegister(local, remote);
store.set(converged);
await pushToPeer(converged);
// Done. No while(true). The merge terminates.
}
The fix selects the CRDT row of the decision matrix (a register on
an unreliable network): a monotonic logical clock (HLC) eliminates
clock skew; a deterministic tiebreak by nodeId eliminates the
equal-timestamp flip. The merge is now a total order that converges
in one pass — the P4 bound. See examples/good/edge-offline-sync.md
for the full good-example version of this pattern.
Cross-Domain Links
domains/edge/sync.md— the CRDT-vs-LWW decision matrix; the wall-clock-only LWW row is the failure mode this example instantiates; the CRDT row is the fix.domains/edge/first-principles.md— P4 (Sync Conflicts are Bounded, Not Infinite) is the principle breached.review/anti-patterns.md— theedge-sync-loopchaos anti-pattern (edge P4, C1, C5 — infinite oscillation is a correctness failure, not eventual consistency; the sync is a livelock).examples/good/edge-offline-sync.md— the good-example version of this pattern: a CRDT register merge that converges in one pass.