Typed values¶
etcd stores bytes. jetcd surfaces those bytes as ByteSequence, and the base recipes do
the same — DistributedQueue.dequeue() hands you a ByteSequence and leaves the
marshalling to you.
That is the right default for a library (it cannot know your wire format), but it is the wrong default for an application, where every call site ends up repeating the same encode/decode dance. A codec moves that decision to one place: you name the format once, and the recipe traffics in your type.
The EtcdCodec interface¶
Two functions. That is the whole contract:
Nothing in the library knows or cares which implementation it holds, which is what lets the Jackson module plug in without core depending on Jackson.
Built-in codecs¶
| Codec | Payload | Notes |
|---|---|---|
ByteSequenceCodec |
ByteSequence |
The identity codec — nothing is marshalled |
StringCodec |
String |
UTF-8 text |
KotlinxJsonCodec<T>(serializer, json) |
any @Serializable |
Prefer the jsonCodec<T>() factory |
// The identity codec: the payload already is a ByteSequence, so nothing is marshalled.
val raw: EtcdCodec<ByteSequence> = ByteSequenceCodec
// UTF-8 text.
val text: EtcdCodec<String> = StringCodec
// kotlinx-serialization JSON for any @Serializable type. The reified factory resolves
// the serializer at the call site, so you never name it yourself.
val orders: EtcdCodec<Order> = jsonCodec<Order>()
jsonCodec<T>(json = Json) is an inline reified factory, so it resolves the serializer at
the call site and you never name it yourself. Construct KotlinxJsonCodec directly only
when you already hold a KSerializer or a customized Json.
The Order used throughout this page is an ordinary @Serializable data class:
Typed key/value¶
The two typed KV extensions are the smallest thing a codec buys you — putValue and
getValue with the hand-marshalling removed:
val codec = jsonCodec<Order>()
// Encodes through the codec and composes the raw ByteSequence put, so it inherits
// that put's retry semantics.
client.putValue("/config/order", Order("A-1", 3), codec)
// Returns null when the key is absent — pick the fallback yourself.
val order: Order? = client.getValue("/config/order", codec)
logger.info { "Read back $order" }
They compose the raw ByteSequence overloads described in Key/value, so
they inherit the same retry semantics. getValue returns null for an absent key rather
than throwing — use ?: default for a fallback.
Java sees these as TypedKVUtils
The file carries @JvmName("TypedKVUtils"), so from Java they are statics with the
client as the first argument. They have no @JvmOverloads, so Java callers must pass
the trailing PutOption / RpcResilience defaults explicitly. See
Jackson for a worked example.
Typed recipes¶
Five recipes take a codec:
| Typed | Wraps | Covered in |
|---|---|---|
TypedDistributedQueue<T> |
DistributedQueue |
Queues |
TypedDistributedPriorityQueue<T> |
DistributedPriorityQueue |
Queues |
TypedPathChildrenCache<T> |
PathChildrenCache |
Caches |
TypedTransientKeyValue<T> |
TransientKeyValue |
Transient key/values |
NodeCache<T> |
— (not a decorator) | Caches |
Each mirrors the API of the recipe it wraps, with T where a ByteSequence used to be:
TypedDistributedQueue(client, "/queues/orders", jsonCodec<Order>()).use { queue ->
queue.enqueue(Order("A-1", 3))
queue.enqueueAll([Order("A-2", 1), Order("A-3", 7)])
// dequeue() hands back an Order, not a ByteSequence.
val order: Order = queue.dequeue()
logger.info { "Dequeued $order" }
}
untyped is the escape hatch¶
This is the one structural thing worth understanding about the typed layer, because it explains an asymmetry you will hit immediately.
The four decorators are Closeable, not EtcdConnector. They wrap a recipe rather
than extending it, so they do not inherit the connector surface described in
Core concepts — no exceptions, no isHealthy(), no
connectionState, no background-exception or connection-state listeners. What they expose
instead is a single public property:
That is the wrapped instance itself, and it is the documented, supported way to reach the full connector API:
TypedDistributedQueue(client, "/queues/orders", jsonCodec<Order>()).use { queue ->
// A decorator is a Closeable, not an EtcdConnector. The whole connector API is
// reached through `untyped` — the very instance the decorator wraps.
queue.untyped.addBackgroundExceptionListener { context, e ->
logger.warn(e) { "Background failure in $context" }
}
if (!queue.untyped.isHealthy()) logger.warn { "Connection lost or closed" }
queue.enqueue(Order("A-1", 3))
queue.untyped.exceptions.forEach { e -> logger.error(e) { "Recorded" } }
}
Why decorate instead of subclass?
A TypedDistributedQueue<Order> is not a DistributedQueue — its dequeue()
returns an Order, not a ByteSequence. Making it a subclass would mean either
overloading every method into an ambiguous mess or lying about the type. Composition
keeps both APIs honest and total: the typed methods are typed, and everything else is
one .untyped away rather than half-reimplemented on the wrapper.
Only the pieces that genuinely change shape are re-exposed on the wrapper — close(),
start(), the listeners whose event types become typed. Everything else, deliberately,
is not.
NodeCache is the exception¶
NodeCache<T> takes a codec but is an EtcdConnector. It is not a decorator: there
is no untyped NodeCache to wrap, because caching a single key was a typed idea from the
start. So its connector API sits directly on the instance, with no untyped hop:
// NodeCache is not a decorator: it is itself an EtcdConnector that happens to take a
// codec, so `exceptions` / `isHealthy()` sit directly on the instance — no `untyped`.
NodeCache(client, "/config/order", jsonCodec<Order>()).use { cache ->
cache.addListener { event -> logger.info { "${event.type} -> ${event.value}" } }
cache.start()
// The live value, decoded on read; null while the key is absent.
val current: Order? = cache.current
logger.info { "Current $current, healthy=${cache.isHealthy()}" }
}
If you find yourself reaching for .untyped on a NodeCache, that is why it isn't there.
Typed caches¶
TypedPathChildrenCache<T> decodes every child value, so currentData yields
TypedChildData<T> and listeners receive decoded events:
TypedPathChildrenCache(client, "/config/orders", jsonCodec<Order>()).use { cache ->
cache.addListener { event -> logger.info { "${event.childName} -> ${event.data}" } }
cache.start(buildInitial = true)
cache.currentData.forEach { child -> logger.info { "${child.key} = ${child.value}" } }
val one: Order? = cache.getCurrentData("A-1")
logger.info { "One $one" }
}
A malformed payload is recorded, not thrown, on the watch path
The read accessors (currentData, getCurrentData, currentDataAsMap, and
NodeCache.current) decode lazily and will throw at your call site if a value is
malformed. But a decode failure on an incoming event cannot be thrown at anyone —
it happens on the watch dispatcher. Those are recorded on untyped.exceptions (on
exceptions for NodeCache) and the event is skipped, so a single bad write does not
kill the cache. Check the exception sink; do not assume silence means health.
Typed transient key/values¶
TypedTransientKeyValue<T> encodes its (immutable) value once and publishes it under a
lease:
// The published value is a String, so this needs a UTF-8 text codec.
TypedTransientKeyValue(client, "/nodes/n1", Order("A-1", 3), jsonCodec<Order>()).use { tkv ->
tkv.addLeaseListener { event -> logger.info { "Lease: $event" } }
// Read it back through the matching typed getValue.
val published: Order? = client.getValue("/nodes/n1", jsonCodec<Order>())
logger.info { "Published $published" }
}
Text codecs only
TransientKeyValue publishes a String, so the typed wrapper requires a codec whose
output is valid UTF-8 text — StringCodec, jsonCodec, or the JSON JacksonCodec. A
binary codec (protobuf, a packed struct) will not survive the round trip through
String. The same restriction applies to service payloads, below, and for the same
reason.
Typed service payloads¶
A ServiceInstance carries an opaque jsonPayload string. The typed helpers layer over
it, leaving the instance's own wire format byte-for-byte unchanged:
val codec = jsonCodec<Order>()
// The typed payload layers over the opaque jsonPayload String, so the ServiceInstance
// wire format is unchanged. That is also why a binary codec is unsupported here.
val instance = serviceInstance("orders", Order("A-1", 3), codec) { apply { port = 8080 } }
ServiceDiscovery(client, "/discovery").use { discovery ->
discovery.registerService(instance)
discovery.queryForInstances("orders").forEach { found ->
val order: Order = found.payload(codec)
logger.info { "${found.name} carries $order" }
}
}
payload(codec) decodes, setPayload(value, codec) encodes in place, and the
serviceInstance(name, payload, codec) builder does it at construction. Because
jsonPayload is a String, these need a UTF-8 text codec — see
Service discovery.
Writing your own codec¶
There is nothing privileged about the built-ins. If your payload is a protobuf message, a
packed binary struct, or a bare Long, implement the interface:
// A codec is two functions and nothing more. Implement EtcdCodec directly when the
// payload is not JSON — a protobuf message, a packed binary struct, a bare Long.
object LongCodec : EtcdCodec<Long> {
override fun encode(value: Long): ByteSequence = value.toString().asByteSequence
override fun decode(bytes: ByteSequence): Long = bytes.asString.toLong()
}
A codec is expected to be thread-safe and side-effect free: recipes call decode from
watch dispatcher threads and encode from whichever thread enqueues. The built-ins are
objects or hold an immutable Json for exactly that reason.
Java and Jackson¶
jsonCodec<T>() is inline and reified, and kotlinx-serialization wants its compiler
plugin — neither travels well to Java. Java callers should use the
Jackson module instead: JacksonCodec<T> implements this same
EtcdCodec<T>, takes a Class token or a TypeReference, and drops into every typed
recipe on this page unchanged.