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 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.