Skip to content

Key/value

etcd is a flat, ordered, byte-to-byte map with a revision attached to every write. Not a tree, despite the slashes: /services/worker-1 is a key whose name happens to contain separators, and "children" are nothing more than a prefix range. Once that clicks, most of this API stops needing explanation.

KVUtils is the layer over jetcd's KV client. It unwraps the CompletableFuture, applies the retry policy, and marshals values so callers stop hand-rolling ByteSequence.from(...).

Putting and getting

client.putValue("/config/name", "orders-service")

// getValue hands back a nullable ByteSequence: absent is a real answer, not an error.
val raw: ByteSequence? = client.getValue("/config/name")
logger.info { "Raw value: ${raw?.asString}" }

// The overloads taking a default collapse "absent" into a value you choose.
val name: String = client.getValue("/config/name", "unset")
logger.info { "Name: $name" }
putValue(client, "/config/name", "orders-service");

// getValue hands back a null ByteSequence: absent is a real answer, not an error.
ByteSequence raw = getValue(client, "/config/name");
System.out.println("Raw value: " + (raw == null ? "null" : getAsString(raw)));

// The overloads taking a default collapse "absent" into a value you choose.
String name = getValue(client, "/config/name", "unset");
System.out.println("Name: " + name);

getValue comes in two shapes, and the difference matters. The single-argument form returns a nullable ByteSequence, because "this key does not exist" is a real answer and not an error. The forms taking a default collapse that into a value you choose — convenient, but they also make an absent key indistinguishable from a key explicitly set to the default. When that distinction carries meaning, take the nullable one.

Puts are retried; that is safe here, but not everywhere

putValue retries on retriable statuses because these values are last-writer-wins: a duplicate apply from an ambiguous first attempt is harmless. This reasoning does not extend to compare-and-swap writes, which is why transaction { } is never retried for you.

Numbers are bytes, not text

// Int and Long are written as fixed-width big-endian bytes, NOT as decimal text,
// so they round-trip through asInt/asLong — never through toString/toInt.
client.putValue("/counters/retries", 3)
client.putValue("/counters/bytes", 9_000_000_000L)
client.putValue("/blobs/payload", "raw bytes".asByteSequence)

val retries: Int = client.getValue("/counters/retries", 0)
val bytes: Long = client.getValue("/counters/bytes", 0L)
logger.info { "retries=$retries bytes=$bytes" }
// Int and Long are written as fixed-width big-endian bytes, NOT as decimal text,
// so they round-trip through getAsInt/getAsLong — never through toString/parseInt.
putValue(client, "/counters/retries", 3);
putValue(client, "/counters/bytes", 9_000_000_000L);
putValue(client, "/blobs/payload", getAsByteSequence("raw bytes"));

int retries = getValue(client, "/counters/retries", 0);
long bytes = getValue(client, "/counters/bytes", 0L);
System.out.printf("retries=%d bytes=%d%n", retries, bytes);

putValue(key, 42) does not write "42"

The Int and Long overloads write fixed-width big-endian bytes (4 and 8 of them), not decimal text. So a value written with the Int overload must be read with asInt, and one written as a String must be read with asString. Mixing them does not throw at the etcd boundary — it hands you a wrong number, or a String full of unprintable bytes.

This is also why the fixed width matters: asInt on an 8-byte value, or asLong on a 4-byte one, is a bug. If a value's type may change over its lifetime, encode it explicitly with a codec rather than relying on every reader remembering which overload the writer used.

Deleting

client.deleteKey("/config/name")

// deleteKeys is a convenience loop, not an atomic multi-delete: each key is its own
// RPC, so a failure partway through leaves the earlier deletes applied. Use a
// transaction when the keys must disappear together.
client.deleteKeys("/config/a", "/config/b", "/config/c")
deleteKey(client, "/config/name");

// deleteKeys is a convenience loop, not an atomic multi-delete: each key is its own
// RPC, so a failure partway through leaves the earlier deletes applied. Use a
// transaction when the keys must disappear together.
deleteKeys(client, "/config/a", "/config/b", "/config/c");

deleteKeys is a loop, not a transaction

Its name suggests a batch, but it issues one RPC per key. A failure halfway through leaves the earlier deletes applied and the rest of the keys alive. When the keys must vanish together, put deleteOps in a transaction; when they are a subtree, use deleteChildren, which is a single ranged delete.

Testing for existence

// Both run a transaction against the key's version rather than fetching its value,
// so a large value costs nothing to test for.
if (client.isKeyPresent("/config/name")) {
  logger.info { "Present" }
}

if (client.isKeyNotPresent("/config/missing")) {
  logger.info { "Absent" }
}
// Both run a transaction against the key's version rather than fetching its value,
// so a large value costs nothing to test for.
if (isKeyPresent(client, "/config/name")) {
  System.out.println("Present");
}

if (isKeyNotPresent(client, "/config/missing")) {
  System.out.println("Absent");
}

Both are implemented as a one-shot transaction on the key's version rather than a GET, so testing for a large value costs nothing extra.

Never branch on presence to decide whether a write will win

isKeyPresent answers "did this key exist at some recent revision?" — never "will it exist when I act on this". Another client may create or delete it between your check and your next line. This is the single most common way to write a race into an etcd program:

// WRONG: two clients can both see false and both write.
if (client.isKeyNotPresent("/locks/leader")) {
  client.putValue("/locks/leader", "node-1")
}

The check and the write must be one atomic step, which is what a transaction is for. Use isKeyPresent for logging, health output, and tests — not for control flow that races.

Reading responses in full

// The full GetResponse, for when you need the revision, the count, or every match.
val response = client.getResponse("/config/name")
logger.info { "Revision: ${response.header.revision}, count: ${response.count}" }

// getKeyValuePairs flattens a GetResponse down to (key, value) pairs.
val pairs: List<Pair<String, ByteSequence>> =
  client.getKeyValuePairs("/config/", getOption { isPrefix(true) })
logger.info { "Config: ${pairs.asString}" }
// The full GetResponse, for when you need the revision, the count, or every match.
GetResponse response = getResponse(client, "/config/name");
System.out.printf("Revision: %d, count: %d%n",
  response.getHeader().getRevision(), response.getCount());

// getKeyValuePairs flattens a GetResponse down to (key, value) pairs.
List<Pair<String, ByteSequence>> pairs =
  getKeyValuePairs(client, "/config/", getOption(builder -> builder.isPrefix(true)));
System.out.println("Config: " + PairUtils.getAsString(pairs));

getResponse is the escape hatch to jetcd's whole GetResponse: the header revision (which you need to anchor a watch), the total count, and every matching KeyValue. getKeyValuePairs is the same call with the response flattened to (key, value) pairs.

getResponse retries a paging quirk for you

When etcd answers with no keys but isMore set, the extension re-issues the GET rather than handing back a misleading empty result — up to ten attempts, after which it throws EtcdRecipeRuntimeException. You will probably never see this; it is here so you do not have to wonder about it.

Children

There are no directories in etcd. Every helper below appends a trailing / to the path and does a prefix range read, which is what makes /services and /services/ equivalent while keeping /servicesX out of the results — a genuinely easy mistake to make by hand.

// Every children helper appends a trailing "/" before its prefix GET, so "/services"
// and "/services/" mean the same thing and "/servicesX" is never swept in by accident.
val children: List<Pair<String, ByteSequence>> = client.getChildren("/services")
val keys: List<String> = client.getChildrenKeys("/services")
val values: List<ByteSequence> = client.getChildrenValues("/services")
val count: Long = client.getChildCount("/services")
logger.info { "$count children: ${children.asString}, keys=$keys, values=${values.size}" }

// Sort by CREATE and first/last become oldest/newest — the server-side ordering that
// the FIFO recipes (queues, election, barriers) are built on.
val oldest = client.getFirstChild("/services", GetOption.SortTarget.CREATE)
val newest = client.getLastChild("/services", GetOption.SortTarget.CREATE)
logger.info { "oldest=${oldest.count} newest=${newest.count}" }

// One ranged delete — atomic, unlike deleteKeys. Returns the keys it removed.
val deleted: List<String> = client.deleteChildren("/services")
logger.info { "Deleted: $deleted" }
// Every children helper appends a trailing "/" before its prefix GET, so "/services"
// and "/services/" mean the same thing and "/servicesX" is never swept in by accident.
List<Pair<String, ByteSequence>> children = getChildren(client, "/services");
List<String> keys = getChildrenKeys(client, "/services");
List<ByteSequence> values = getChildrenValues(client, "/services");
long count = getChildCount(client, "/services");
System.out.printf("%d children: %s, keys=%s, values=%d%n",
  count, PairUtils.getAsString(children), keys, values.size());

// Sort by CREATE and first/last become oldest/newest — the server-side ordering that
// the FIFO recipes (queues, election, barriers) are built on.
GetResponse oldest = getFirstChild(client, "/services", GetOption.SortTarget.CREATE);
GetResponse newest = getLastChild(client, "/services", GetOption.SortTarget.CREATE);
System.out.printf("oldest=%d newest=%d%n", oldest.getCount(), newest.getCount());

// One ranged delete — atomic, unlike deleteKeys. Returns the keys it removed.
List<String> deleted = deleteChildren(client, "/services");
System.out.println("Deleted: " + deleted);
Function Returns
getChildren List<Pair<String, ByteSequence>> — keys and values
getChildrenKeys List<String> — keys only, and a cheaper read (withKeysOnly)
getChildrenValues List<ByteSequence> — values only
getChildCount Long — a count-only read; no values cross the wire
getFirstChild / getLastChild GetResponse limited to one key
deleteChildren List<String> — the keys it removed, from prevKvs

getChildren, getChildrenKeys, and getChildrenValues all take a SortTarget and SortOrder, defaulting to KEY/ASCEND. Sorting by CREATE is the interesting one: etcd assigns create revisions server-side, so ordering by them is a cluster-wide agreement on who arrived first. That single fact is what getFirstChild(path, SortTarget.CREATE) provides, and it is the foundation the queue, election, and barrier recipes are built on.

deleteChildren is atomic; deleteKeys is not

deleteChildren compiles to one ranged delete RPC, so the subtree disappears at a single revision. Prefer it over fetching the keys and looping.

Converting values

etcd stores bytes. These extensions are the entire marshalling story, and they are all in ByteSequenceUtils, KeyValueUtils, PairUtils, and PathUtils.

// etcd stores bytes and nothing else; these extensions are the whole marshalling story.
val keyBytes: ByteSequence = "/config/name".asByteSequence
val countBytes: ByteSequence = 42.asByteSequence
val stampBytes: ByteSequence = 1_700_000_000L.asByteSequence
logger.info { "${keyBytes.asString} ${countBytes.asInt} ${stampBytes.asLong}" }

// KeyValue and Pair get the same treatment, one element or a whole list at a time.
val kvs = client.getResponse("/config/", getOption { isPrefix(true) }).kvs
val firstPair: Pair<String, ByteSequence>? = kvs.firstOrNull()?.asPair
val decoded: List<Pair<String, String>> = kvs.map { it.asPair }.asString
logger.info { "first=${firstPair?.asString} all=$decoded keys=${decoded.keys} values=${decoded.values}" }

// appendToPath joins path segments without doubling or dropping the separator.
logger.info { "/services".appendToPath("worker-1") }
// etcd stores bytes and nothing else; these helpers are the whole marshalling story.
ByteSequence keyBytes = getAsByteSequence("/config/name");
ByteSequence countBytes = getAsByteSequence(42);
ByteSequence stampBytes = getAsByteSequence(1_700_000_000L);
System.out.printf("%s %d %d%n",
  getAsString(keyBytes), getAsInt(countBytes), getAsLong(stampBytes));

// Kotlin's extension properties become get-prefixed statics on the facade class:
// `kv.asPair` in Kotlin is `KeyValueUtils.getAsPair(kv)` here.
List<KeyValue> kvs =
  getResponse(client, "/config/", getOption(builder -> builder.isPrefix(true))).getKvs();
if (!kvs.isEmpty()) {
  Pair<String, ByteSequence> first = KeyValueUtils.getAsPair(kvs.get(0));
  System.out.println("First: " + PairUtils.getAsString(first));
}

List<Pair<String, ByteSequence>> pairs = getChildren(client, "/services");
System.out.printf("keys=%s values=%d%n",
  PairUtils.getKeys(pairs), PairUtils.getValues(pairs).size());

System.out.println(appendToPath("/services", "worker-1"));
Extension On Gives
asByteSequence String, Int, Long ByteSequence
asString / asInt / asLong ByteSequence the decoded value
asPair KeyValue Pair<String, ByteSequence>
asString / asInt / asLong KeyValue, Pair, List<Pair> decoded pairs
keys / values List<Pair<String, T>> List<String> / List<T>
appendToPath String a joined path with exactly one separator

appendToPath looks trivial and is not: it strips a trailing separator from the receiver and a leading one from the suffix, so "/services/".appendToPath("/worker-1") and "/services".appendToPath("worker-1") both give /services/worker-1. Hand-rolled string concatenation produces // about as often as not, and a double slash is a different key — one that silently drops out of the prefix reads you expect to find it in.

Compaction

val current = client.getResponse("/config/name").header.revision

// Discards all history at or below this revision to bound etcd's disk growth. Any
// watcher still anchored below it dies with a CompactedException — which is exactly
// what the resilient watcher's resyncWith hook exists to absorb.
client.compact(current)
long current = getResponse(client, "/config/name").getHeader().getRevision();

// Discards all history at or below this revision to bound etcd's disk growth. Any
// watcher still anchored below it dies with a CompactedException — which is exactly
// what the resilient watcher's resyncWith hook exists to absorb.
compact(client, current);

etcd keeps every historical revision until told otherwise, so compaction is how a long-lived cluster's disk usage stays bounded. It is normally an operator's job (etcd can do it on a timer), but the call is here when you need it.

Compaction kills watchers anchored below the compacted revision

A watch resuming from revision N cannot be served once N has been compacted away — etcd fails the stream with CompactedException, and the events in the gap are unrecoverable. Any derived state built from that stream is now wrong and cannot be repaired by replaying.

This is not a hypothetical: it is the failure the resilient watcher's resyncWith hook exists to absorb, by re-reading the world and re-anchoring. See Watches.

Typed values

The ByteSequence overloads leave marshalling to you, which gets old quickly and gets dangerous when a writer and a reader disagree. TypedKVUtils takes an EtcdCodec<T> so the encoding lives in one place:

// The codec owns encode and decode on both sides, so no hand-marshalling survives
// at the call site. StringCodec here; jsonCodec<T>() for your own types.
client.putValue("/config/greeting", "hello", StringCodec)

val greeting: String? = client.getValue("/config/greeting", StringCodec)
logger.info { "Greeting: ${greeting ?: "unset"}" }
// TypedKVUtils carries no @JvmOverloads, so Java supplies every argument — there is
// no shorter form the way Kotlin's default parameters provide one.
TypedKVUtils.putValue(client, "/config/greeting", "hello",
  StringCodec.INSTANCE, PutOption.DEFAULT, RpcResilience.DEFAULT);

String greeting =
  TypedKVUtils.getValue(client, "/config/greeting", StringCodec.INSTANCE, RpcResilience.DEFAULT);
System.out.println("Greeting: " + (greeting == null ? "unset" : greeting));

getValue(key, codec) returns T? — null for an absent key, exactly like the untyped form. There is no default-taking overload; use ?: default.

Java must pass every argument here

TypedKVExtensions.kt does not carry @JvmOverloads, so Java callers supply PutOption.DEFAULT and RpcResilience.DEFAULT explicitly rather than getting the short form Kotlin's default parameters provide.

Built-in codecs are StringCodec, ByteSequenceCodec, and jsonCodec<T>() for anything @Serializable. The full picture, including which recipes accept a codec, is on Typed values.