29ffb42898
---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---
158 lines
6.9 KiB
Markdown
158 lines
6.9 KiB
Markdown
# Bad Example: Edge Sync Loop (Livelock)
|
|
|
|
> A two-node sync that oscillates divergent state forever. This is
|
|
> the pre-specified `edge-sync-loop` chaos 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
|
|
|
|
```typescript
|
|
// 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.md` is 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
|
|
|
|
```typescript
|
|
// 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` — the `edge-sync-loop` chaos
|
|
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. |