consensus.rodmena.co.uk

Strongly-consistent distributed key-value store for Rodmena services.

etcd 3.5.17 3-member Raft quorum linearizable gRPC + HTTP/JSON auth required

What it is for. Coordination: leader election, distributed locks, service discovery, feature flags, live configuration — anything where several processes must agree and the answer must be correct rather than fast.

What it is not. A general-purpose database, a cache, a queue, or a blob store. Values are small, the whole keyspace lives in memory, and every write costs a quorum round trip.

Everything on this page was verified against the live service through its public HTTPS interface. The machine-readable version is at /llms.txt.

Two ways in

InterfaceEndpointUse when
gRPC
native etcd protocol
consensus.rodmena.co.uk:443 You have a real etcd client — etcdctl, Go, Java, Rust. Best performance and native streaming.
HTTP/JSON
etcd gRPC-gateway
https://consensus.rodmena.co.uk/v3/… You want plain HTTPS from any language, no SDK.

Both reach the same cluster and the same data. TLS is an ordinary publicly-trusted certificate — no custom CA, no client certificates.

Authentication

Exchange credentials for a token, then send that token on every request.

TOKEN=$(curl -s -X POST https://consensus.rodmena.co.uk/v3/auth/authenticate \
  -d '{"name":"YOUR_USER","password":"YOUR_PASSWORD"}' \
  | python3 -c 'import sys,json; print(json.load(sys.stdin)["token"])')
No Bearer prefix. Send the raw token: Authorization: $TOKEN. Adding Bearer returns {"error":"etcdserver: invalid auth token"}. This is the single most common integration mistake.

Tokens are JWTs with a 30 minute TTL, signed by the cluster. Treat HTTP 401 or invalid auth token as “re-authenticate once and retry”, not as fatal — a long-lived process that authenticates only at startup will start failing after 30 minutes. An unauthenticated request fails with {"error":"etcdserver: user name is empty"}.

A cluster restart does not log you out. Tokens are signed, so any member can verify one — they are not held in the memory of whichever member issued them. This was verified by restarting all three members while holding a single token. Rolling upgrades are therefore invisible to applications.

Two encoding rules that will bite you

1. Keys and values are base64

In the HTTP/JSON API every key and value is base64-encoded, in requests and responses — etcd stores arbitrary bytes and JSON cannot carry bytes. Forget it and you will successfully store a key literally named L215YXBwL2tleQ==. gRPC clients are unaffected.

2. Every integer is a string

All 64-bit fields are rendered as JSON strings, because JSON numbers cannot hold int64 safely:

{"header":{"revision":"13","raft_term":"2"},"count":"1",
 "kvs":[{"create_revision":"3","mod_revision":"3","version":"1"}]}

Cast explicitly. "13" > "9" is false as a string comparison, and lease IDs overflow a JavaScript number — keep them as strings.

Key-value operations

Examples use b64() { printf "%s" "$1" | base64 -w0; }.

Put

curl -s -X POST https://consensus.rodmena.co.uk/v3/kv/put \
  -H "Authorization: $TOKEN" \
  -d "{\"key\":\"$(b64 /myapp/config/timeout)\",\"value\":\"$(b64 30s)\"}"

Get

curl -s -X POST https://consensus.rodmena.co.uk/v3/kv/range \
  -H "Authorization: $TOKEN" \
  -d "{\"key\":\"$(b64 /myapp/config/timeout)\"}"
A miss returns no kvs array and no count field at all — not an empty list, not count: 0. Always use response.get("kvs", []).

List a prefix

There is no prefix flag. Pass range_end = the prefix with its last byte incremented. For /myapp/ that is /myapp0.

curl -s -X POST https://consensus.rodmena.co.uk/v3/kv/range \
  -H "Authorization: $TOKEN" \
  -d "{\"key\":\"$(b64 /myapp/)\",\"range_end\":\"$(b64 /myapp0)\"}"

Delete

curl -s -X POST https://consensus.rodmena.co.uk/v3/kv/deleterange \
  -H "Authorization: $TOKEN" \
  -d "{\"key\":\"$(b64 /myapp/config/timeout)\"}"

Transactions — the reason you are here

A transaction is comparesuccess or failure, applied atomically across the cluster. This is the compare-and-swap primitive that makes locks and leader election possible.

Create a key only if nobody else has (version = 0 means “does not exist”):

curl -s -X POST https://consensus.rodmena.co.uk/v3/kv/txn \
  -H "Authorization: $TOKEN" \
  -d "{
    \"compare\":[{\"key\":\"$(b64 /myapp/leader)\",\"target\":\"VERSION\",
                  \"result\":\"EQUAL\",\"version\":\"0\"}],
    \"success\":[{\"requestPut\":{\"key\":\"$(b64 /myapp/leader)\",
                                  \"value\":\"$(b64 node-a)\"}}],
    \"failure\":[]
  }"
On failure, succeeded is omitted entirely rather than being false. Test with resp.get("succeeded") is True. Reading resp["succeeded"] raises on exactly the branch you care about.

Compare targets: VERSION, CREATE, MOD, VALUE. Comparing on MOD gives you optimistic concurrency — read a key, note its mod_revision, write only if it has not changed.

Leases — keys that expire on their own

A lease is a TTL handle; keys attached to it vanish when it lapses. This is how you get liveness without cleanup code: if your process dies, its registration disappears by itself.

# grant a 10 second lease
LID=$(curl -s -X POST https://consensus.rodmena.co.uk/v3/lease/grant \
  -H "Authorization: $TOKEN" -d '{"TTL":10}' \
  | python3 -c 'import sys,json; print(json.load(sys.stdin)["ID"])')

# attach a key to it
curl -s -X POST https://consensus.rodmena.co.uk/v3/kv/put \
  -H "Authorization: $TOKEN" \
  -d "{\"key\":\"$(b64 /myapp/nodes/node-a)\",\"value\":\"$(b64 10.0.0.5:8080)\",\"lease\":\"$LID\"}"

# hold it open -- send at roughly TTL/3
curl -s -X POST https://consensus.rodmena.co.uk/v3/lease/keepalive \
  -H "Authorization: $TOKEN" -d "{\"ID\":\"$LID\"}"

Verified: the key persists while keepalives continue and disappears within a few seconds of them stopping.

Watch — react to changes

Watch is a long-lived streaming response, one JSON object per line.

curl -sN -X POST https://consensus.rodmena.co.uk/v3/watch \
  -H "Authorization: $TOKEN" \
  -d "{\"create_request\":{\"key\":\"$(b64 /myapp/config/)\",
                           \"range_end\":\"$(b64 /myapp/config0)\"}}"

The first object confirms creation; later objects carry events. Each event has a type (absent means PUT) and a kv. Use curl -N so nothing is buffered.

After a reconnect, pass start_revision — your last processed revision plus one — so you miss nothing. Without it there is a gap.

Python, without an etcd SDK

This client is run against the live service as part of the deployment's test suite, so it is known to work as written.

import base64, httpx

BASE = "https://consensus.rodmena.co.uk"

def b64(s: str) -> str:  return base64.b64encode(s.encode()).decode()
def unb64(s: str) -> str: return base64.b64decode(s).decode()

class Consensus:
    def __init__(self, user: str, password: str):
        self.c = httpx.Client(base_url=BASE, timeout=10.0)
        self.token = self.c.post(
            "/v3/auth/authenticate", json={"name": user, "password": password}
        ).json()["token"]
        self.c.headers["Authorization"] = self.token   # no "Bearer"

    def put(self, key: str, value: str) -> int:
        r = self.c.post("/v3/kv/put", json={"key": b64(key), "value": b64(value)})
        r.raise_for_status()
        return int(r.json()["header"]["revision"])

    def get(self, key: str) -> str | None:
        r = self.c.post("/v3/kv/range", json={"key": b64(key)})
        r.raise_for_status()
        kvs = r.json().get("kvs", [])        # absent on a miss, not an empty list
        return unb64(kvs[0]["value"]) if kvs else None

    def list_prefix(self, prefix: str) -> dict[str, str]:
        end = prefix[:-1] + chr(ord(prefix[-1]) + 1)
        r = self.c.post("/v3/kv/range",
                        json={"key": b64(prefix), "range_end": b64(end)})
        r.raise_for_status()
        return {unb64(kv["key"]): unb64(kv["value"])
                for kv in r.json().get("kvs", [])}

    def create_if_absent(self, key: str, value: str) -> bool:
        """Atomic create. True if we won the race, False if it already existed."""
        r = self.c.post("/v3/kv/txn", json={
            "compare": [{"key": b64(key), "target": "VERSION",
                         "result": "EQUAL", "version": "0"}],
            "success": [{"requestPut": {"key": b64(key), "value": b64(value)}}],
            "failure": [],
        })
        r.raise_for_status()
        return r.json().get("succeeded") is True   # absent means False

Go — native gRPC client

import (
    "context"
    "crypto/tls"
    "time"
    clientv3 "go.etcd.io/etcd/client/v3"
)

cli, _ := clientv3.New(clientv3.Config{
    Endpoints:   []string{"consensus.rodmena.co.uk:443"},
    DialTimeout: 5 * time.Second,
    Username:    "YOUR_USER",
    Password:    "YOUR_PASSWORD",
    TLS:         &tls.Config{},   // zero value uses the system roots
})
defer cli.Close()

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_, err := cli.Put(ctx, "/myapp/config/timeout", "30s")
cancel()

etcdctl

etcdctl --endpoints=https://consensus.rodmena.co.uk:443 \
        --user USER:PASSWORD get --prefix /myapp/

Consistency

Limits

LimitValuePast it
Request body1.5 MBRejected
General API rate100 req/s per IP, burst 200HTTP 503
Authenticate rate10 req/s per IP, burst 20HTTP 503
Stream lifetime1 hourClosed; reconnect with start_revision
History retentioncompacted hourlyWatch from an older revision fails

Blocked at the edge

These return 403 even with valid admin credentials. They are operator actions performed on the host, not over the internet.

/v3/maintenance/snapshot        /v3/cluster/member/add
/v3/maintenance/defragment      /v3/cluster/member/remove
/v3/maintenance/downgrade       /v3/cluster/member/update
/v3/maintenance/alarm           /v3/cluster/member/promote
/v3/maintenance/transfer-leadership
/v3/auth/disable

The equivalent gRPC methods are blocked too. A full-database snapshot over the public internet is total data exfiltration in one request, so it is refused regardless of who asks.

Health

curl -s https://consensus.rodmena.co.uk/health
# {"health":"true","reason":""}

Unauthenticated and safe to poll from a load balancer.

What this deployment does and does not survive

All three members run on a single host. The cluster tolerates losing any one member — restart, upgrade, crash — and keeps serving reads and writes. It does not survive loss of that host. Treat it as a strongly-consistent coordination service with single-host availability, not a geo-redundant system, and keep a backup elsewhere for anything whose loss you could not tolerate.

Getting access

Credentials are issued per application by the operator. Ask farshid@rodmena.co.uk for a user scoped to your prefix. Use one credential per service — a prefix-scoped user means a leak is contained to that prefix. Namespace your keys as /yourapp/… from day one so that stays possible.