Skip to content
academia.sh

Lesson 11 / 13

PACELC

The latency trade-off outside a partition: naming the region where the CAP theorem stays silent, measuring the synchronous-acknowledgment and asynchronous-propagation policies with no partition in terms of the acknowledgment-wait step, messages per write, and the staleness window seen on reads, showing the sensitivity of the arrival-interval and latency assumptions, and classifying a system separately on its two branches.

Contents

One column in the previous lesson’s measurement was left unexplained. Reject spent 12 messages, respond spent 6; the difference came from every answered read being verified with the neighbor, and those messages were sent while the link was up too. So the decision about consistency does not end even if the network never partitions.

CAP is silent in this region: with no partition, the theorem has nothing to say. Yet the decision is still there, paid on every request. The PACELC theorem fills the place where CAP stays silent, and that is this lesson’s subject.

The Theorem’s Second Branch

PACELC is a two-branch sentence. The first branch is what CAP says: if there is a partition (P), a choice is made between service availability (A) and consistency (C). The second branch is new: else (E — with no partition), a choice is made between latency (L) and consistency (C).

The second branch’s rationale is a simple fact: for a replica to verify its held value is current, it must talk to its neighbor. That conversation crosses the network and has a cost, independent of any partition — even with a perfectly healthy link, every verification needs a round trip. So consistency is not free even with no outage; only the currency paid changes — not a rejected request, but time waited.

Two Write Policies With No Partition

The choice being measured lies on the write path. A carrier status event can be handled two ways: have both replicas acknowledge every write (acknowledged), or write to one replica, acknowledge the carrier right away, and propagate to the neighbor afterward (propagated). The model below runs both policies over the same event sequence, with the link never down.

This is a model, not a measurement: a round is an abstract time step, latency D is a parameter in rounds, and no clock is read anywhere — so the output is the same on every run.

// pacelc/model.mjs — two WRITE policies while there is NO partition. A round is an abstract time
// step; the latency D is a model parameter in rounds, not a measured duration. Events for the
// same tracking number are ordered, so at most one write is ever in flight.
export function run({ rounds: N, latency: D, interval, policy }) {
  const write = new Map(); // writeNo -> { arrival, accepted, visible }
  const queue = [];
  let flight = null, messages = 0, seq = 0;

  for (let t = 1; t <= N; t++) {
    if ((t - 1) % interval === 0) {
      seq += 1;
      write.set(seq, { arrival: t, accepted: null, visible: null });
      queue.push(seq);
    }
    if (policy === "acknowledged") {
      if (flight !== null && flight.ends === t) {
        const w = write.get(flight.no);
        w.accepted = t;   // acknowledgment arrived: the write becomes visible on both replicas in the same round
        w.visible = t;
        messages += 2;    // outbound + acknowledgment
        flight = null;
      }
      if (flight === null && queue.length > 0) flight = { no: queue.shift(), ends: t + 2 * D };
    } else {
      while (queue.length > 0) {
        const w = write.get(queue.shift());
        w.accepted = t;       // written to the local replica, the carrier is acknowledged right away
        w.visible = t + D;    // reaches the neighbor D rounds later
        messages += 1;
      }
    }
  }

  const all = [...write.values()];
  const accepted = all.filter((w) => w.accepted !== null);
  const visible = accepted.filter((w) => w.visible <= N);
  const max = (a, f) => Math.max(0, ...a.map(f));
  const avg = (a, f) => (a.length === 0 ? 0 : a.reduce((t, w) => t + f(w), 0) / a.length);
  return {
    arrived: all.length, accepted: accepted.length, messages,
    avgWait: avg(accepted, (w) => w.accepted - w.arrival),
    messagesPerWrite: accepted.length === 0 ? 0 : messages / accepted.length,
    maxStaleness: max(visible, (w) => w.visible - w.accepted),
    maxEndToEnd: max(visible, (w) => w.visible - w.arrival),
  };
}

The model’s four numbers each need their own definition. Wait is the difference between the round an event arrives and the round the carrier is acknowledged. Staleness window is the difference between the round a write is acknowledged and the round it becomes visible on the second replica. End-to-end is the difference between arrival and visibility on the second replica. Messages is the number spent per accepted write.

// pacelc/measure.mjs — the trade-off on the E branch: two policies, two arrival intervals, three latencies
import { run } from "./model.mjs";

const ROUNDS = 24;
const pad = (x, n) => String(x).padStart(n);
console.log(`no partition. ${ROUNDS} rounds. arrival interval and latency D are assumptions; a round is an abstract step.`);
console.log();
console.log("interval | D | policy       | accepted | avg wait    | pending  | msgs/write  | staleness | end-to-end");
console.log("---------|---|--------------|----------|-------------|----------|-------------|-----------|-----------");
for (const interval of [1, 4]) {
  for (const D of [1, 2, 4]) {
    for (const policy of ["acknowledged", "propagated"]) {
      const r = run({ rounds: ROUNDS, latency: D, interval, policy });
      console.log(`${pad(interval, 8)} | ${pad(D, 1)} | ${policy.padEnd(12)} | ` +
        `${pad(`${r.accepted}/${r.arrived}`, 8)} | ${pad(r.avgWait.toFixed(1), 11)} | ` +
        `${pad(r.arrived - r.accepted, 8)} | ${pad(r.messagesPerWrite.toFixed(2), 11)} | ` +
        `${pad(`${r.maxStaleness} round`, 9)} | ${pad(`${r.maxEndToEnd} round`, 10)}`);
    }
  }
}
node pacelc/measure.mjs
no partition. 24 rounds. arrival interval and latency D are assumptions; a round is an abstract step.

interval | D | policy       | accepted | avg wait    | pending  | msgs/write  | staleness | end-to-end
---------|---|--------------|----------|-------------|----------|-------------|-----------|-----------
       1 | 1 | acknowledged |    11/24 |         7.0 |       13 |        2.00 |   0 round |   12 round
       1 | 1 | propagated   |    24/24 |         0.0 |        0 |        1.00 |   1 round |    1 round
       1 | 2 | acknowledged |     5/24 |        10.0 |       19 |        2.00 |   0 round |   16 round
       1 | 2 | propagated   |    24/24 |         0.0 |        0 |        1.00 |   2 round |    2 round
       1 | 4 | acknowledged |     2/24 |        11.5 |       22 |        2.00 |   0 round |   15 round
       1 | 4 | propagated   |    24/24 |         0.0 |        0 |        1.00 |   4 round |    4 round
       4 | 1 | acknowledged |      6/6 |         2.0 |        0 |        2.00 |   0 round |    2 round
       4 | 1 | propagated   |      6/6 |         0.0 |        0 |        1.00 |   1 round |    1 round
       4 | 2 | acknowledged |      5/6 |         4.0 |        1 |        2.00 |   0 round |    4 round
       4 | 2 | propagated   |      6/6 |         0.0 |        0 |        1.00 |   2 round |    2 round
       4 | 4 | acknowledged |      2/6 |        10.0 |        4 |        2.00 |   0 round |   12 round
       4 | 4 | propagated   |      6/6 |         0.0 |        0 |        1.00 |   4 round |    4 round

Reading the Numbers

The two cleanest columns are staleness and messages. The acknowledged policy’s staleness window is 0 rounds in all six runs; the propagated policy’s is exactly D rounds — 1, 2, and 4. This turns the second branch’s definition into a number: asynchronous propagation trades consistency for a window as wide as the latency parameter. In exchange, messages per write drop from 2.00 to 1.00. The acknowledgment message is the fee consistency pays even with no outage.

The wait column shows who that fee is billed to. In the propagated policy, wait is 0.0 rounds in every run: the carrier gets its acknowledgment right away. In the acknowledged policy, wait at arrival interval 4 is exactly 2D2D rounds — 2.0 and 4.0. At sparse arrivals, the cost of synchronous acknowledgment is predictable and constant.

The runs at arrival interval 1 show something quite different: wait climbs to 7.0, 10.0, and 11.5, and pending reaches 13, 19, and 22. The reason: events under the same tracking number must stay ordered, so at most one write is ever in flight, and the policy can accept a write only once every 2D2D rounds. Once the arrival interval falls below 2D2D, the queue grows. This is the same independence seen in the Latency and Throughput lesson: per-write latency stays fixed at 2D2D while throughput falls, and falling throughput inflates the wait. What to do with the queue — reject it, slow it down, apply back pressure — is the subject of the Caching, Queues and Asynchronous Processing course and is not revisited here.

There is a reading trap in the end-to-end column. At arrival interval 1 and D=4D = 4, this number is 15, while at D=2D = 2 it is 16 — smaller, but not better: the column is computed only over accepted writes, and in that row only 2 of 24 events were accepted. The end-to-end latency of the 22 events still queued is not yet defined. Near saturation, an average latency is misleading; the latency column should not be read before the pending column is.

Finally, the acknowledged policy’s end-to-end number is not 0. Synchronous acknowledgment does not let the reader see the truth sooner; it keeps the system from promising something it cannot yet verify. Staleness comes out 0 because that measure sits between what the system promises and what gets read. The distance between the event and the value read does not close — it just becomes invisible.

Classifying the Two Branches Separately

A system is not named with a single letter but classified separately for its two branches. The read policies from the previous lesson and the write policies from this one produce four combinations.

Class Under partition With no partition Policy pair
PC/EC consistency consistency reject + acknowledged
PA/EL service availability latency respond + propagated
PC/EL consistency latency reject + propagated
PA/EC service availability consistency over latency respond + acknowledged

The third row looks strange at first but is a coherent design: writes propagate asynchronously while the link is up, and a read is rejected once it goes down. The system is fast, and instead of getting something wrong during a partition it stays silent; the price is a D-round staleness window even while the link is up.

Classification, again, is per flow rather than for the whole system. In the tracking service, the tracking query can choose the PA/EL branch and end-of-day billing the PC/EC branch; both run over the same replicas. Billing a shipment against a stale status produces a wrong invoice even within a two-round window; a tracking query showing a status two rounds stale is a tolerable error.

Summary

  • PACELC is a two-branch sentence: under a partition (P), the choice is between service availability (A) and consistency (C); with no partition (E), between latency (L) and consistency (C).
  • In the model, the acknowledged policy’s staleness window was 0 rounds in every run; the propagated policy’s was exactly as wide as the latency parameter — 1, 2, and 4 rounds.
  • Consistency’s fee outside a partition showed up in the message column: 2.00 messages per write versus 1.00.
  • At arrival interval 4, the acknowledged policy’s wait stayed fixed at 2D2D rounds (2.0 and 4.0); once the interval dropped to 1, the queue grew and wait climbed from 7.0 to 11.5, with pending events rising from 13 to 22.
  • Synchronous acknowledgment does not let the reader see the truth sooner; end-to-end latency remains — the system only stops promising a value it cannot verify.
  • Classification is done separately for the two branches and per flow: the tracking query can sit on the PA/EL branch, billing on the PC/EC branch.

Next Step

In this lesson’s measurement, the staleness window collapsed to a single number: 0 or D rounds. That is only the coarse shape of the problem. What a reader sees inside a D-round window depends less on the window’s width than on which guarantee is given. Two clients querying the same shipment at once can see different statuses; one can see a sequence that goes backward instead of forward; a third can fail to find the event it just wrote on its next query. All of this happens inside the same D window, and none of it substitutes for the rest. The next lesson names these guarantees — strong, weak, and eventual consistency — reading the same event sequence under three read policies and counting how often different values are seen, how many rounds convergence takes, and how many monotonic-read violations occur.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close