Caches¶
A cache turns a watch into a local read. Instead of asking etcd for a value every time you need it, you snapshot once, keep a watch open, and read from memory — so the hot path never blocks on a network round trip.
etcd-recipes ships three:
| Recipe | Caches | Typed |
|---|---|---|
NodeCache<T> |
One key | Always — takes an EtcdCodec<T> |
PathChildrenCache |
Every child of a prefix | No — raw ByteSequence |
TypedPathChildrenCache<T> |
Every child of a prefix | Yes — decorates the above |
NodeCache and PathChildrenCache extend EtcdConnector, so they share the lifecycle,
exception, and connection-state surface described in
Core concepts. TypedPathChildrenCache does not — see
below.
Listeners run on a watch dispatcher¶
Read this before you write a listener.
Never block a cache listener on anything that needs etcd
Watcher callbacks originate on jetcd's Vert.x event loop. Anything that needs
another gRPC response — a get, a put, a lock acquisition — or that contends for
a lock the caller holds while that caller is waiting on gRPC, deadlocks the event
loop. Not "slows it down": stops it, for every watch in the process, permanently.
The library's Client.watcher extension hops callbacks onto a dedicated
single-thread executor, which buys back the simple cases. It does not make
listeners safe in general: a listener that blocks on a mutex held by a thread that
is itself parked on an etcd call still hangs the dispatcher, and once the dispatcher
hangs the cache stops converging while continuing to look healthy.
Treat a listener as a notification, not a workflow. Copy what you need, hand it to your own executor, and return.
Each cache gets its own dispatcher thread, and it is single-threaded on purpose: that is
what makes events arrive in etcd's order. A slow listener therefore delays every later
event for that cache — and the watch-recovery loop, including its resync GET, runs on
that same thread. Keep listeners short even when they cannot deadlock.
NodeCache<T>¶
One key, kept hot, decoded through an EtcdCodec<T>. This is the "watch my config value"
recipe.
NodeCache(client, "/config/greeting", StringCodec).use { cache ->
// start() snapshots the key, then anchors the watch at the snapshot revision + 1.
cache.start()
// Reads are local: no round trip, and no chance of blocking on a slow etcd.
logger.info { "Current value: ${cache.current}" }
}
NodeCache<T> is generic and takes a codec, so it is presented in Kotlin throughout this
section. It is callable from Java — see the Java guide — but the codec
generics make it markedly less pleasant there; Java callers usually want
PathChildrenCache.
Why start() snapshots first, then anchors the watch¶
start() is one-shot: it throws if called twice, or after close(). What it does in
between matters more than it looks.
It reads the key, remembers the revision that read was answered at, and then opens the watch at snapshot revision + 1. That ordering is the whole recipe:
- Watch first, snapshot second, and a
PUTlanding in between gets seen by the watch and then overwritten by the older snapshot. The cache silently serves a stale value forever. - Snapshot first, watch from "now", and a
PUTlanding in between is missed by both. Same outcome, different race. - Snapshot first, watch from the snapshot's revision + 1, and the stream picks up exactly where the snapshot left off — no gap, no overlap.
This is etcd's standard bootstrap, and the same fix is applied everywhere this library establishes a watch. It is also why compaction of the watched revision re-syncs transparently: the resync is the same snapshot-and-re-anchor operation.
Reading¶
current decodes on read and returns null when the key is absent. currentBytes hands
back the raw ByteSequence and skips the codec.
// Any EtcdCodec<T> works: StringCodec, ByteSequenceCodec, jsonCodec<T>(), or the
// Jackson codec from etcd-recipes-jackson.
NodeCache(client, "/config/flags", jsonCodec<FeatureFlags>()).use { cache ->
cache.start()
// current decodes on read, so a malformed payload throws here, at your call site.
val flags: FeatureFlags? = cache.current
logger.info { "Max batch size: ${flags?.maxBatchSize}" }
// currentBytes hands back the raw value and skips the codec entirely.
logger.info { "Raw: ${cache.currentBytes}" }
}
current decodes at your call site
Decoding lazily means a malformed payload throws where you read it, not on the
dispatcher thread where nobody is listening. Events are the other way round: a
payload that fails to decode in the listener path is recorded on exceptions and the
event is skipped, because killing the dispatcher over one bad value would stop the
cache updating at all.
Listening¶
NodeCache(client, "/config/greeting", StringCodec).use { cache ->
// Register listeners before start(): the watch begins delivering the moment
// start() returns, and nothing replays what you were not listening for.
cache.addListener { event: NodeCacheEvent<String> ->
when (event.type) {
NodeCacheEvent.Type.CREATED -> logger.info { "Created: ${event.value}" }
NodeCacheEvent.Type.UPDATED -> logger.info { "Updated: ${event.value}" }
// value is null on DELETED: the key is gone.
NodeCacheEvent.Type.DELETED -> logger.info { "Deleted" }
}
}
cache.start()
}
NodeCacheEvent<T> carries a type — CREATED, UPDATED, or DELETED — and the decoded
value, which is null on DELETED. CREATED means "the cache had no value and now
does", which is a statement about the cache, not about etcd: a key that already existed
when you called start() produces UPDATED on its next change, because the snapshot
already put a value in hand.
Register listeners before start(). The watch begins delivering the moment start()
returns, and nothing replays what you were not listening for.
Watch recovery¶
NodeCache(client, "/config/greeting", StringCodec).use { cache ->
cache.addRecoveryListener { event: WatchRecoveryEvent ->
when (event) {
is WatchRecoveryEvent.Suspended -> logger.warn { "Watch stream died; retrying" }
is WatchRecoveryEvent.Resubscribed -> logger.info { "Watch re-established" }
is WatchRecoveryEvent.Resynced -> logger.info { "Compaction: re-snapshotted the key" }
// Recovery abandoned: current is frozen at whatever it last saw.
is WatchRecoveryEvent.Failed -> logger.error { "Watch abandoned; value is stale" }
}
}
cache.start()
}
A WatchRecoveryEvent.Failed is the one that matters: recovery has been abandoned, so
current is frozen at whatever it last saw and will never move again. It is also recorded
on exceptions and drives connectionState to LOST. See
Resilience.
Scoped use¶
val greeting: String? =
withNodeCache(client, "/config/greeting", StringCodec) {
start()
current
}
logger.info { "Greeting: $greeting" }
PathChildrenCache¶
A whole prefix. Every child of cachePath is held in memory and kept current by one
prefix watch.
PathChildrenCache(client, "/cache/workers").use { cache ->
// buildInitial = true snapshots the prefix before the watch starts, so the cache
// is already populated by the time start() returns.
cache.start(buildInitial = true)
cache.currentData.forEach { child: ChildData ->
logger.info { "${child.key} -> ${child.value.asString}" }
}
}
try (PathChildrenCache cache = new PathChildrenCache(client, "/cache/workers")) {
// true = snapshot the prefix before the watch starts, so the cache is already
// populated by the time start() returns.
cache.start(true);
for (ChildData child : cache.getCurrentData()) {
System.out.println(child.getKey() + " -> " + child.getValue().toString(StandardCharsets.UTF_8));
}
}
Start modes¶
StartMode decides what the cache knows when start() returns:
| Mode | Snapshot | INITIALIZED event |
|---|---|---|
NORMAL |
No — starts empty | No |
BUILD_INITIAL_CACHE |
Yes | No |
POST_INITIALIZED_EVENT |
Yes | Yes, carrying the snapshot |
// NORMAL: no snapshot. The cache starts empty and fills only from live events.
PathChildrenCache(client, "/cache/workers").use { cache ->
cache.start(PathChildrenCache.StartMode.NORMAL)
}
// BUILD_INITIAL_CACHE: snapshot first, then watch from the snapshot revision + 1.
PathChildrenCache(client, "/cache/workers").use { cache ->
cache.start(PathChildrenCache.StartMode.BUILD_INITIAL_CACHE)
}
// POST_INITIALIZED_EVENT: BUILD_INITIAL_CACHE, plus an INITIALIZED event carrying
// the snapshot in event.initialData.
PathChildrenCache(client, "/cache/workers").use { cache ->
cache.start(PathChildrenCache.StartMode.POST_INITIALIZED_EVENT)
}
// NORMAL: no snapshot. The cache starts empty and fills only from live events.
try (PathChildrenCache cache = new PathChildrenCache(client, "/cache/workers")) {
cache.start(PathChildrenCache.StartMode.NORMAL);
}
// BUILD_INITIAL_CACHE: snapshot first, then watch from the snapshot revision + 1.
try (PathChildrenCache cache = new PathChildrenCache(client, "/cache/workers")) {
cache.start(PathChildrenCache.StartMode.BUILD_INITIAL_CACHE);
}
// POST_INITIALIZED_EVENT: BUILD_INITIAL_CACHE, plus an INITIALIZED event carrying
// the snapshot in getInitialData().
try (PathChildrenCache cache = new PathChildrenCache(client, "/cache/workers")) {
cache.start(PathChildrenCache.StartMode.POST_INITIALIZED_EVENT);
}
There are two start() overloads. start(buildInitial, waitOnStartComplete) is the
convenience form — buildInitial = true maps to BUILD_INITIAL_CACHE, false to
NORMAL — and start(mode, waitOnStartComplete) is the one that can reach
POST_INITIALIZED_EVENT. Both default waitOnStartComplete to true.
NORMAL does not start with what is already there
NORMAL skips the snapshot entirely and watches from now. Children that existed
before you started are invisible until something touches them. That is occasionally
what you want (you only care about changes), and almost never what people mean when
they reach for a cache. Prefer BUILD_INITIAL_CACHE.
Waiting for the snapshot¶
In the two priming modes the snapshot loads on a background thread.
waitOnStartComplete = true (the default) makes start() block until it has finished, so
currentData is populated when you get control back. Pass false when start() must not
block your caller, then bound the wait yourself:
PathChildrenCache(client, "/cache/workers").use { cache ->
// waitOnStartComplete = false returns before the snapshot has loaded, so the
// cache may still be empty. Useful when start() must not block your caller.
cache.start(PathChildrenCache.StartMode.BUILD_INITIAL_CACHE, waitOnStartComplete = false)
// ...then bound the wait yourself. Returns false if the snapshot is still loading.
if (cache.waitOnStartComplete(10.seconds))
logger.info { "Primed with ${cache.currentData.size} children" }
else
logger.warn { "Snapshot still loading after 10s" }
}
try (PathChildrenCache cache = new PathChildrenCache(client, "/cache/workers")) {
// false = return before the snapshot has loaded, so the cache may still be empty.
cache.start(PathChildrenCache.StartMode.BUILD_INITIAL_CACHE, false);
// ...then bound the wait yourself. Java uses the (long, TimeUnit) overload.
if (cache.waitOnStartComplete(10, TimeUnit.SECONDS)) {
System.out.println("Primed with " + cache.getCurrentData().size() + " children");
} else {
System.out.println("Snapshot still loading after 10s");
}
}
waitOnStartComplete() with no argument waits indefinitely; the Duration and
(long, TimeUnit) overloads bound it and return false on timeout. All three throw
InterruptedException.
Reading¶
PathChildrenCache(client, "/cache/workers").use { cache ->
cache.start(buildInitial = true)
// Sorted by child name, so iteration order is stable across clients.
cache.currentData.forEach { logger.info { "${it.key} -> ${it.value.asString}" } }
// getCurrentData() takes the child name RELATIVE to the cache path, not a full
// path: "/cache/workers/w1" returns null, "w1" is what you want.
val w1: ByteSequence? = cache.getCurrentData("w1")
logger.info { "w1 -> ${w1?.asString}" }
// A point-in-time copy of the whole prefix.
val snapshot: Map<String, ByteSequence> = cache.currentDataAsMap
logger.info { "Holding ${snapshot.size} children" }
}
try (PathChildrenCache cache = new PathChildrenCache(client, "/cache/workers")) {
cache.start(true);
// Sorted by child name, so iteration order is stable across clients.
List<ChildData> children = cache.getCurrentData();
System.out.println("Holding " + children.size() + " children");
// The child name RELATIVE to the cache path, not a full path:
// "/cache/workers/w1" returns null, "w1" is what you want.
ByteSequence w1 = cache.getCurrentData("w1");
System.out.println("w1 -> " + (w1 == null ? "absent" : w1.toString(StandardCharsets.UTF_8)));
// A point-in-time copy of the whole prefix.
Map<String, ByteSequence> snapshot = cache.getCurrentDataAsMap();
System.out.println("Snapshot size: " + snapshot.size());
}
currentData returns List<ChildData> sorted by child name, so iteration order is stable
across clients and across restarts. currentDataAsMap is an unsorted point-in-time copy.
getCurrentData() takes a child name, not a path
The keys are relative to cachePath: "w1", not "/cache/workers/w1". Passing a
full path returns null — silently, because a missing child is a legitimate answer.
This trips people up exactly once.
Listening¶
PathChildrenCache(client, "/cache/workers").use { cache ->
cache.addListener { event: PathChildrenCacheEvent ->
when (event.type) {
PathChildrenCacheEvent.Type.CHILD_ADDED -> logger.info { "Added ${event.childName}" }
PathChildrenCacheEvent.Type.CHILD_UPDATED -> logger.info { "Updated ${event.childName}" }
// data carries the value the child held immediately before removal.
PathChildrenCacheEvent.Type.CHILD_REMOVED -> logger.info { "Removed ${event.childName}" }
// Only INITIALIZED events populate initialData; it is empty on the others.
PathChildrenCacheEvent.Type.INITIALIZED -> logger.info { "Primed: ${event.initialData.size}" }
}
}
cache.start(PathChildrenCache.StartMode.POST_INITIALIZED_EVENT)
}
try (PathChildrenCache cache = new PathChildrenCache(client, "/cache/workers")) {
cache.addListener(event -> {
// A switch expression rather than a statement: the compiler enforces that
// every Type is handled, so a new event type becomes a compile error here
// instead of an event this listener silently drops.
String message = switch (event.getType()) {
case CHILD_ADDED -> "Added " + event.getChildName();
case CHILD_UPDATED -> "Updated " + event.getChildName();
// getData() carries the value the child held immediately before removal.
case CHILD_REMOVED -> "Removed " + event.getChildName();
// Only INITIALIZED events populate getInitialData().
case INITIALIZED -> "Primed: " + event.getInitialData().size();
};
System.out.println(message);
});
cache.start(PathChildrenCache.StartMode.POST_INITIALIZED_EVENT);
}
PathChildrenCacheEvent has a childName, a type — CHILD_ADDED, CHILD_UPDATED,
CHILD_REMOVED, INITIALIZED — and data. On CHILD_REMOVED, data is the value the
child held immediately before removal, which is usually what you need to clean up after
it. initialData is populated only on INITIALIZED and is empty on every other event.
rebuild() and clear()¶
PathChildrenCache(client, "/cache/workers").use { cache ->
cache.start(buildInitial = true)
// A coarse manual re-sync against etcd's current children. The watcher already
// keeps the cache converged, so this is a repair tool, not part of steady state:
// a live watch event on the same key can race the snapshot, last writer wins.
cache.rebuild()
// Drops every entry locally without touching etcd. The next event or rebuild()
// refills it; until then currentData reports nothing.
cache.clear()
}
try (PathChildrenCache cache = new PathChildrenCache(client, "/cache/workers")) {
cache.start(true);
// A coarse manual re-sync against etcd's current children. The watcher already
// keeps the cache converged, so this is a repair tool, not part of steady state.
cache.rebuild();
// Drops every entry locally without touching etcd.
cache.clear();
}
rebuild() re-snapshots the prefix and reconciles the live map in place — it does not
clear first, so currentData never reports an empty window mid-rebuild. clear() drops
every entry locally without touching etcd.
rebuild() is a repair tool, not a refresh
The watcher already keeps the cache converged, including across compaction. A manual
rebuild() races the live watch on any key that changes during the snapshot, and last
writer wins — so it can momentarily un-apply an event you already saw. Rely on the
watcher for ordering; reach for rebuild() when you have reason to believe the cache
drifted.
Watch recovery and resilience¶
PathChildrenCache(client, "/cache/workers").use { cache ->
cache.addRecoveryListener { event: WatchRecoveryEvent ->
when (event) {
is WatchRecoveryEvent.Suspended -> logger.warn { "Watch stream died; retrying" }
is WatchRecoveryEvent.Resubscribed -> logger.info { "Watch re-established" }
is WatchRecoveryEvent.Resynced -> logger.info { "Compaction: cache re-synced" }
// Recovery abandoned: the cache is frozen and will never update again.
is WatchRecoveryEvent.Failed -> logger.error { "Watch abandoned; cache is stale" }
}
}
cache.start(buildInitial = true)
}
addRecoveryListener reports resubscribes after fatal stream deaths, compaction resyncs,
and abandonment. Failed means the cache is now frozen: it will keep answering reads with
data that no longer reflects etcd. That is the failure mode worth alerting on, and it is
why the event is also recorded on exceptions and moves connectionState to LOST. See
Resilience and Observability.
Scoped use¶
val names: List<String> =
withPathChildrenCache(client, "/cache/workers") {
start(buildInitial = true)
currentData.map { it.key }
}
logger.info { "Workers: $names" }
withPathChildrenCache does not expose resilience
Its parameters are (client, cachePath, userExecutor, receiver) — there is no
resilience argument on this overload. Construct PathChildrenCache directly when
you need a non-default ResilienceConfig:
// withPathChildrenCache does not expose resilience; construct directly for that.
PathChildrenCache(
client = client,
cachePath = "/cache/workers",
userExecutor = null,
resilience = ResilienceConfig.DEFAULT,
).use { cache ->
cache.start(buildInitial = true)
logger.info { "Children: ${cache.currentData.size}" }
}
TypedPathChildrenCache<T>¶
The same prefix cache with every child value decoded through an EtcdCodec<T>:
currentData yields TypedChildData<T>, getCurrentData() yields T?, and
currentDataAsMap yields Map<String, T>.
TypedPathChildrenCache(client, "/cache/workers", jsonCodec<Worker>()).use { cache ->
cache.start(buildInitial = true)
cache.currentData.forEach { child: TypedChildData<Worker> ->
logger.info { "${child.key} -> ${child.value.name} (load ${child.value.load})" }
}
// Same relative-child-name rule as the untyped cache, decoded on the way out.
val w1: Worker? = cache.getCurrentData("w1")
logger.info { "w1 -> $w1" }
}
See Typed values for the codec options.
It is a decorator, not a connector¶
TypedPathChildrenCache<T> implements Closeable. It does not extend
EtcdConnector. That means exceptions, hasExceptions, isHealthy(),
connectionState, ping(), and the connection-state listeners are not on it.
They are all on untyped, which is a public property and the documented escape hatch:
TypedPathChildrenCache(client, "/cache/workers", jsonCodec<Worker>()).use { cache ->
cache.start(buildInitial = true)
// TypedPathChildrenCache is a Closeable decorator, NOT an EtcdConnector. The
// connector surface lives on `untyped`, which is the documented escape hatch.
val untyped: PathChildrenCache = cache.untyped
// A payload that fails to decode is recorded here and its event skipped, rather
// than killing the watch dispatcher. Nothing else tells you it happened.
untyped.exceptions.forEach { logger.warn(it) { "Cache background failure" } }
logger.info { "Healthy: ${untyped.isHealthy()}, state: ${untyped.connectionState}" }
// Raw, undecoded views of the same cache are reachable through it too.
logger.info { "Raw children: ${untyped.currentDataAsMap.keys}" }
}
This matters more than the usual "there's an escape hatch if you need it", because of how
decode failures surface. A payload the codec cannot read is caught in the re-emit path,
recorded on untyped.exceptions, and its event is skipped. The typed listener simply
never fires for that child. If you are not reading untyped.exceptions, a poison value in
your prefix looks exactly like a child that never changed.
Listening¶
TypedPathChildrenCache(client, "/cache/workers", jsonCodec<Worker>()).use { cache ->
// The event is a TypedPathChildrenCacheEvent<Worker>, but its `type` is the
// untyped PathChildrenCacheEvent.Type: there is no separate typed enum.
cache.addListener { event ->
when (event.type) {
PathChildrenCacheEvent.Type.CHILD_ADDED -> logger.info { "Added ${event.data?.name}" }
PathChildrenCacheEvent.Type.CHILD_UPDATED -> logger.info { "Updated ${event.data?.name}" }
PathChildrenCacheEvent.Type.CHILD_REMOVED -> logger.info { "Removed ${event.childName}" }
PathChildrenCacheEvent.Type.INITIALIZED -> logger.info { "Primed: ${event.initialData.size}" }
}
}
cache.start(PathChildrenCache.StartMode.POST_INITIALIZED_EVENT)
}
TypedPathChildrenCacheEvent<T> reuses the untyped Type enum
There is no TypedPathChildrenCacheEvent.Type. The event's type is a
PathChildrenCacheEvent.Type — the same CHILD_ADDED / CHILD_UPDATED /
CHILD_REMOVED / INITIALIZED values. Only the payload is typed: data is T? and
initialData is List<TypedChildData<T>>.
Scoped use¶
val loads: Map<String, Int> =
withTypedPathChildrenCache(client, "/cache/workers", jsonCodec<Worker>()) {
start(buildInitial = true)
currentDataAsMap.mapValues { (_, worker) -> worker.load }
}
logger.info { "Loads: $loads" }
Unlike its untyped counterpart, withTypedPathChildrenCache does take a resilience
argument, after userExecutor.
Coroutines¶
All three caches expose their events as flows — eventsAsFlow() and
recoveryEventsAsFlow() — which is the better answer to the dispatcher problem at the top
of this page. A flow buffers (unlimited by default), so a slow collector cannot stall the
watch dispatcher the way a slow listener does, and collection happens on your dispatcher
rather than the cache's. Collecting registers a listener and cancelling removes it; it
never starts or closes the cache, so you still own the lifecycle. See
Flows.
Observability¶
With the Micrometer module wired in, every snapshot load —
the initial prime, each rebuild(), and each compaction resync — reports an
etcd.cache.sync timer and an etcd.cache.size distribution summary. Cache paths never
become tags, because prefix cardinality is unbounded. See
Observability.
A steadily climbing etcd.cache.sync count on an idle cache means something is resyncing
it repeatedly — usually compaction chasing a watch that keeps falling behind.