Coroutines¶
Every recipe in etcd-recipes has a blocking API, and every blocking call that waits on a network round trip has a suspending twin. The twins do not reimplement anything: they are a thin bridge over the same recipes, so a coroutine and a thread contending for the same lock are the same client to etcd.
This page is Kotlin-only
The coroutines layer has no Java equivalent — it is built on suspend functions
and Flow, neither of which Java can call. Java callers use the blocking API
described in Recipes; see the Java guide for
the idioms (try/finally in place of withLock, listeners in place of flows).
The naming convention¶
A blocking method foo() gets a suspending twin awaitFoo(), taking the same
arguments and returning the same type:
client.awaitPutValue("/config/mode", "active")
val mode = client.awaitGetValue("/config/mode", "unknown")
logger.info { "Mode is $mode" }
// Transactions get the operation timeout but are never retried: a failed commit
// is ambiguous, so the CAS retry decision stays yours.
val claimed =
client.awaitTransaction {
If("/leases/job-1".doesNotExist)
Then("/leases/job-1" setTo "worker-a")
}
logger.info { "Claimed the job: ${claimed.isSucceeded}" }
Two families deliberately break the pattern, because awaitWaitOnBarrier() and
awaitDequeue() read worse than the alternative:
| Blocking | Suspending |
|---|---|
DistributedBarrier.waitOnBarrier() |
DistributedBarrier.await() |
DistributedBarrierWithCount.waitOnBarrier() |
DistributedBarrierWithCount.await() |
AbstractQueue.dequeue() |
AbstractQueue.receive() |
AbstractQueue.poll(timeout) |
AbstractQueue.receive(timeout) |
Everything else is await + the blocking name.
The surface, by area¶
Lifecycle — LifecycleSuspend.kt¶
Starting a recipe is itself a set of round trips, so it suspends too.
| Suspending | Blocking twin |
|---|---|
TransientKeyValue.awaitStart() |
start() |
DistributedAtomicLong.awaitStart() |
start() |
PathChildrenCache.awaitStart(buildInitial) / awaitStart(mode) |
start(...) |
PathChildrenCache.awaitStartComplete() / awaitStartComplete(timeout) |
waitOnStartComplete(...) |
LeaderSelector.awaitStart() |
start() |
LeaderSelector.awaitLeadershipComplete() / (timeout) |
waitOnLeadershipComplete(...) |
LeaderSelector.awaitFinished() / (timeout) |
waitUntilFinished(...) |
PathChildrenCache(client, "/services").use { cache ->
// Both the start RPCs and the wait for the initial snapshot happen off-thread.
cache.awaitStart(buildInitial = true)
cache.awaitStartComplete(5.seconds)
logger.info { "Cache holds ${cache.currentData.size} children" }
}
Barriers — BarrierSuspend.kt¶
| Suspending | Blocking twin |
|---|---|
DistributedBarrier.awaitSetBarrier() |
setBarrier() |
DistributedBarrier.awaitRemoveBarrier() |
removeBarrier() |
DistributedBarrier.await() / await(timeout) |
waitOnBarrier(...) |
DistributedBarrierWithCount.await() / await(timeout) |
waitOnBarrier(...) |
DistributedDoubleBarrier.awaitEnter() / awaitEnter(timeout) |
enter(...) |
DistributedDoubleBarrier.awaitLeave() / awaitLeave(timeout) |
leave(...) |
DistributedDoubleBarrier(client, "/barriers/phase-1", memberCount = 5).use { barrier ->
barrier.awaitEnter(30.seconds)
logger.info { "All five members arrived; running phase 1" }
barrier.awaitLeave(30.seconds)
}
See Barriers.
Counters — CounterSuspend.kt¶
| Suspending | Blocking twin |
|---|---|
DistributedAtomicLong.awaitGet() |
get() |
DistributedAtomicLong.awaitIncrement() / awaitDecrement() |
increment() / decrement() |
DistributedAtomicLong.awaitAdd(value) / awaitSubtract(value) |
add(value) / subtract(value) |
DistributedAtomicLong(client, "/counters/orders").use { counter ->
counter.awaitStart()
val next = counter.awaitIncrement()
val total = counter.awaitGet()
logger.info { "Order $next of $total" }
}
Queues — QueueSuspend.kt¶
| Suspending | Blocking twin |
|---|---|
AbstractQueue.receive() |
dequeue() |
AbstractQueue.receive(timeout) |
poll(timeout) |
AbstractQueue.awaitTryDequeue() |
tryDequeue() |
DistributedQueue.awaitEnqueue(value) |
enqueue(value) |
DistributedQueue.awaitEnqueueAll(values) |
enqueueAll(values) |
DistributedPriorityQueue.awaitEnqueue(value, priority) |
enqueue(value, priority) |
DistributedQueue(client, "/queues/jobs").use { queue ->
queue.awaitEnqueue("job-1")
// receive() parks the coroutine, not a thread: thousands of consumers cost
// thousands of continuations rather than thousands of stacks.
val item = queue.receive()
logger.info { "Got ${item.asString}" }
}
awaitEnqueue is overloaded for ByteSequence, String, Int, and Long, matching
the blocking API; the priority-queue overloads take either a UShort or an Int
priority. See Queues, and Typed values
for the codec-backed variants.
Work queues — WorkQueueSuspend.kt¶
| Suspending | Blocking twin |
|---|---|
DistributedWorkQueue.awaitEnqueue(value) / awaitEnqueue(value, delay) |
enqueue(...) |
DistributedWorkQueue.awaitEnqueueAll(values) |
enqueueAll(values) |
DistributedWorkQueue.awaitReceive() / awaitReceive(timeout) |
receive(...) |
DistributedWorkQueue.awaitTryReceive() |
tryReceive() |
WorkItem.awaitAck() |
ack() |
WorkItem.awaitRequeue() |
requeue() |
Service discovery — DiscoverySuspend.kt¶
| Suspending | Blocking twin |
|---|---|
ServiceDiscovery.awaitRegisterService(service) |
registerService(service) |
ServiceDiscovery.awaitUpdateService(service) |
updateService(service) |
ServiceDiscovery.awaitUnregisterService(service) |
unregisterService(service) |
ServiceDiscovery.awaitQueryForNames() |
queryForNames() |
ServiceDiscovery.awaitQueryForInstances(name) |
queryForInstances(name) |
ServiceDiscovery.awaitQueryForInstance(name, id) |
queryForInstance(name, id) |
ServiceCache.awaitStart() |
start() |
Key/value — KVSuspend.kt¶
| Suspending | Blocking twin |
|---|---|
Client.awaitPutValue(keyName, keyval, option, rpc) |
putValue(...) |
Client.awaitGetValue(keyName, rpc) / with a String/Int/Long default |
getValue(...) |
Client.awaitGetResponse(keyName, option, rpc) |
getResponse(...) |
Client.awaitGetKeyValuePairs(keyName, getOption, rpc) |
getKeyValuePairs(...) |
Client.awaitDeleteKey(keyName, rpc) / awaitDeleteKeys(vararg keyNames) |
deleteKey(...) / deleteKeys(...) |
Client.awaitIsKeyPresent(keyName, rpc) / awaitIsKeyNotPresent(...) |
isKeyPresent(...) / isKeyNotPresent(...) |
Client.awaitCompact(revision, option, rpc) |
compact(...) |
Children — ChildrenSuspend.kt¶
| Suspending | Blocking twin |
|---|---|
Client.awaitGetChildren(keyName, target, order, keysOnly, rpc) |
getChildren(...) |
Client.awaitGetChildrenKeys(...) / awaitGetChildrenValues(...) |
getChildrenKeys(...) / getChildrenValues(...) |
Client.awaitGetFirstChild(keyName, target, rpc) / awaitGetLastChild(...) |
getFirstChild(...) / getLastChild(...) |
Client.awaitGetChildCount(keyName, rpc) |
getChildCount(...) |
Client.awaitDeleteChildren(keyName, rpc) |
deleteChildren(...) |
Transactions — TxnSuspend.kt¶
| Suspending | Blocking twin |
|---|---|
Client.awaitTransaction(rpc) { } |
transaction(rpc) { } |
Like its blocking twin, awaitTransaction applies the operation timeout but is
never retried. A failed commit is ambiguous — it may already have been applied —
so re-issuing it is the caller's decision, not the library's. See
Transactions.
Leases — LeaseSuspend.kt¶
| Suspending | Blocking twin |
|---|---|
Client.awaitLeaseGrant(ttl, rpc) |
leaseGrant(ttl, rpc) |
Client.awaitLeaseRevoke(lease, rpc) |
leaseRevoke(lease, rpc) |
awaitLeaseRevoke is best-effort: it runs on cleanup paths where a secondary failure
would mask the original problem, so failures are logged and swallowed and the TTL
bounds how long the lease can outlive you. Cancellation still propagates.
Raw locks — LockSuspend.kt¶
| Suspending | Blocking twin |
|---|---|
Client.awaitLock(keyName, leaseId, rpc) |
lock(keyName, leaseId, rpc) |
Client.awaitUnlock(keyName, rpc) |
unlock(keyName, rpc) |
These are pass-throughs to etcd's lock service. Prefer
DistributedMutex, which manages the lease, the reentrancy,
and the lock-lost notification for you.
// The raw pass-throughs; prefer DistributedMutex. awaitLock defaults to
// RpcResilience.DISABLED because the lock RPC legitimately waits server-side.
val lease = client.awaitLeaseGrant(10.seconds)
try {
val lock = client.awaitLock("/locks/raw", lease.id, rpc = RpcResilience.DISABLED)
try {
logger.info { "Holding ${lock.key.asString}" }
} finally {
client.awaitUnlock("/locks/raw")
}
} finally {
client.awaitLeaseRevoke(lease)
}
awaitLock defaults to RpcResilience.DISABLED
Every other suspending twin defaults to RpcResilience.DEFAULT — four attempts,
a 30-second per-attempt deadline. awaitLock is the one exception, and the
reason is that a lock call is supposed to take a long time: it waits
server-side until the current holder releases. A 30-second operation timeout
would abort perfectly healthy waits and turn ordinary contention into a stream of
spurious failures.
Pass a bounded RpcResilience only when a bounded wait is genuinely what you
mean — and remember that a lock acquisition that times out client-side may still
have been granted server-side.
Locks and semaphores — LockRecipesSuspend.kt¶
Covered in the next two sections.
Cancellation¶
This is the part worth reading properly, because it is where the coroutine layer earns its keep.
The recipe twins — everything built on a blocking recipe rather than a raw jetcd call
— run their blocking body through an internal bridge (Bridges.kt) on
Dispatchers.IO:
internal suspend fun <T> interruptibleOn(dispatcher: CoroutineDispatcher, block: () -> T): T
internal suspend fun <T> etcdInterruptible(block: () -> T): T // interruptibleOn(Dispatchers.IO)
Three things follow, and they are the whole contract:
1. Cancellation interrupts the worker thread. runInterruptible means a cancelled
coroutine does not merely stop waiting for the result — it interrupts the thread that
is actually blocked in the recipe. Nothing is left spinning in the background.
2. The recipe's cleanup still runs, and runs to completion. The blocking recipes
already handle interruption: a finally block revokes the acquisition lease, deletes
the queue entry, or removes the barrier waiter. runInterruptible does not abandon
the thread — it waits for that cleanup to finish before your catch sees anything.
So a cancelled DistributedBarrierWithCount.await() removes its waiter and stops
counting toward the barrier; a cancelled queue.receive() consumes nothing; a
cancelled withLock acquisition leaves nothing queued behind a lock it will never
take. Cancellation is safe, not merely fast.
3. The failure reaches you as CancellationException. The blocking RPC engine
catches a mid-call InterruptedException and rethrows it wrapped in an
EtcdRecipeRuntimeException — sometimes wrapped twice, because it re-sets the
interrupt flag first so a subsequent RPC on the same thread can wrap it again.
runInterruptible cannot recognise either as cancellation. The bridge therefore walks
the entire cause chain, and since a thread inside runInterruptible is interrupted
only by coroutine cancellation, an InterruptedException anywhere in that chain
unambiguously means "you were cancelled" — and is re-surfaced as
CancellationException.
val consumer =
scope.launch(Dispatchers.Default) {
DistributedQueue(client, "/queues/jobs").use { queue ->
try {
val item = queue.receive()
logger.info { "Got ${item.asString}" }
} catch (e: CancellationException) {
// The worker thread was interrupted, the recipe's finally-block cleanup
// (lease revoke / entry delete) already ran, and the interrupt reaches
// you here as CancellationException — never as a raw EtcdRecipe*Exception.
logger.info { "Consumer shut down cleanly: ${e.message}" }
throw e
}
}
}
consumer.cancel()
Catch CancellationException, not EtcdRecipeRuntimeException
On a shutdown path, an etcd exception that is really "we cancelled you" would be indistinguishable from a genuine etcd failure — and would light up your error dashboards on every clean shutdown. The bridge exists so that never happens. Structured concurrency's normal rules apply: catch it only to log, then rethrow.
Why not an isActive check?
The obvious implementation — catch the exception, ask whether the Job is
cancelled, and translate if so — is racy. The interrupt can be delivered a hair
before the Job's state flips to cancelled, which lets a "failure" escape from
what was really a cancellation. Inspecting the cause chain has no such window.
Composing with withTimeout / withTimeoutOrNull works exactly as you would expect,
and is usually nicer than the bounded overloads when you want one deadline over
several calls:
DistributedQueue(client, "/queues/jobs").use { queue ->
// Cancelling a parked receive() consumes nothing: the wait is interrupted, the
// recipe's cleanup runs, and the queue is left exactly as it was found.
val item = withTimeoutOrNull(3.seconds) { queue.receive() }
if (item == null) logger.info { "Nothing to do; idling" }
}
The raw extension twins don't use a thread at all
KVSuspend, ChildrenSuspend, TxnSuspend, LeaseSuspend, and LockSuspend
wrap jetcd's CompletableFuture API, not a blocking recipe, so they never park a
thread: they await() the future directly, applying RpcResilience with delay
instead of Thread.sleep for backoff. Cancelling one cancels the in-flight
future and propagates immediately — external cancellation is never retried. The
observable contract is the same; only the cost differs. See
Resilience.
Locks are scoped-only; semaphores are not¶
EtcdLock gets exactly one suspending surface — withLock — while
DistributedSemaphore gets a split awaitAcquire / awaitRelease pair and
withPermit. That asymmetry is not an oversight; it falls straight out of what the
two recipes mean.
An EtcdLock hold is owned by the acquiring thread. lock() and unlock() must
run on the same thread, or unlock() throws IllegalMonitorStateException. A
coroutine has no such guarantee: it can resume on a different thread of
Dispatchers.IO after every suspension point. A raw suspending lock()/unlock()
pair would therefore be a loaded gun — correct in testing, and broken in production
the first time the dispatcher hands the continuation to another thread.
So withLock does the confining for you: it spins up one dedicated single-threaded
dispatcher per call, acquires and releases on that thread, and runs your body in the
caller's coroutine, where it may suspend freely.
DistributedMutex(client, "/locks/orders").use { mutex ->
// Scoped-only: acquisition and release are confined to one dedicated thread,
// while the body runs in the caller's coroutine and may suspend freely.
mutex.withLock {
logger.info { "Critical section, without pinning a thread to the wait" }
}
}
The release leg runs under NonCancellable, so cancelling the body still releases the
lock rather than leaking the hold until lease expiry. There is a bounded variant, and
a null return always means "not acquired" — nothing was left queued:
DistributedMutex(client, "/locks/orders").use { mutex ->
// A null return always means "not acquired" — nothing was left queued.
val result =
mutex.withLock(5.seconds) {
logger.info { "Acquired within the timeout" }
"done"
}
if (result == null) logger.info { "Someone else holds the lock; moving on" }
}
The suspending withLock is not reentrant
The blocking withLock is reentrant, tracked by holdCount. The suspending one
is not: each call is an independent acquisition on a fresh thread, so nesting
withLock on the same lock self-deadlocks — the inner acquisition waits for a
hold the outer one will not release until the inner one returns. This matches
kotlinx's Mutex, and it is the price of confinement. The blocking API's
write→read downgrade does not carry across calls either.
A DistributedSemaphore permit is owned by the instance, not by a thread. Any
thread may release a permit that another acquired — the recipe follows
java.util.concurrent.Semaphore's rules, not a lock's. Dispatcher hopping is
therefore harmless, and the split surface is safe:
| Suspending | Blocking twin |
|---|---|
DistributedSemaphore.awaitAcquire() |
acquire() |
DistributedSemaphore.awaitTryAcquire(timeout) |
tryAcquire(timeout) |
DistributedSemaphore.awaitRelease() |
release() |
DistributedSemaphore.awaitAvailablePermits() |
availablePermits() |
DistributedSemaphore.withPermit { } |
— |
DistributedSemaphore(client, "/semaphores/uploads", permits = 3).use { semaphore ->
// Permits are held by the instance, not by a thread, so the split surface is
// safe across dispatcher hops — acquire here, release from anywhere.
semaphore.awaitAcquire()
try {
val left = semaphore.awaitAvailablePermits()
logger.info { "$left permits left" }
} finally {
semaphore.awaitRelease()
}
}
withPermit is still the form to reach for, for the same reason use beats a manual
close():
DistributedSemaphore(client, "/semaphores/uploads", permits = 3).use { semaphore ->
semaphore.withPermit {
logger.info { "At most three uploaders cluster-wide run this" }
}
}
Star-importing both packages breaks withLock at compile time
io.etcd.recipes.lock and io.etcd.recipes.coroutines each declare a
withLock extension on EtcdLock — one inline and blocking, one suspending.
A file that star-imports both gets an overload-resolution ambiguity on every
withLock { } call, and the compiler cannot pick for you: the lambdas differ only
in whether they are suspend.
import io.etcd.recipes.lock.* // ✗ brings in the blocking withLock
import io.etcd.recipes.coroutines.* // ✗ brings in the suspending withLock
Import the one you mean explicitly:
import io.etcd.recipes.lock.DistributedMutex
import io.etcd.recipes.coroutines.withLock // ✓ unambiguous
The same applies to withPermit. This is a deliberate trade: the alternative was
a second name (withLockSuspending) that would have been uglier on every call
site in exchange for a nuisance the compiler catches immediately.
Flows¶
Everything above is request/response. Watches, cache events, leadership hand-offs,
lease transitions, and connection state are push, and they get Flow surfaces
instead — see Flows.