Skip to content

Flows

The suspending API covers request/response. Everything that etcd or a recipe pushes at you — watch events, cache changes, leadership hand-offs, lease transitions, connection state, background failures — is exposed as a Flow, as the coroutine-native alternative to registering a listener.

This page is Kotlin-only

The coroutines layer has no Java equivalent. Every flow on this page wraps a listener that Java can register directly against the blocking API described in Recipes; see the Java guide.

The whole surface

Flow Element Buffering
Client.watchAsFlow(keyName, option, resilience, resyncWith, capacity) WatchFlowEvent capacity
Client.watchEventsAsFlow(keyName, option, resilience, capacity) WatchEvent capacity
Client.leadershipAsFlow(electionPath, resilience, capacity) LeadershipEvent capacity
PathChildrenCache.eventsAsFlow(capacity) PathChildrenCacheEvent capacity
PathChildrenCache.recoveryEventsAsFlow(capacity) WatchRecoveryEvent capacity
NodeCache<T>.eventsAsFlow(capacity) NodeCacheEvent<T> capacity
NodeCache<*>.recoveryEventsAsFlow(capacity) WatchRecoveryEvent capacity
TypedPathChildrenCache<T>.eventsAsFlow(capacity) TypedPathChildrenCacheEvent<T> capacity
TypedPathChildrenCache<*>.recoveryEventsAsFlow(capacity) WatchRecoveryEvent capacity
ServiceCache.eventsAsFlow(capacity) ServiceCacheEvent capacity
ServiceCache.recoveryEventsAsFlow(capacity) WatchRecoveryEvent capacity
TransientKeyValue.leaseEventsAsFlow(capacity) LeaseEvent capacity
DistributedWorkQueue.leaseEventsAsFlow(capacity) LeaseEvent capacity
ServiceRegistry.leaseEventsAsFlow(capacity) LeaseEvent capacity
EtcdConnector.connectionStateAsFlow() ConnectionState conflated
EtcdConnector.backgroundExceptionsAsFlow(capacity) BackgroundException capacity
EtcdLock.lockLostAsFlow() LockLostEvent unbounded, not tunable
DistributedSemaphore.permitLostAsFlow() PermitLostEvent unbounded, not tunable

capacity defaults to Channel.UNLIMITED everywhere it appears. The next section explains why, and when to change it.

The shared contract

Every flow on this page is built the same way, and understanding the pattern once tells you how all of them behave:

callbackFlow {
  val listener = SomeListener { event -> trySendBlocking(event) }
  addSomeListener(listener)
  awaitClose { removeSomeListener(listener) }
}.buffer(capacity = Channel.UNLIMITED)

Four consequences:

Collecting subscribes; cancelling unsubscribes. The listener is registered when collection starts and removed in awaitClose when the collector is cancelled or the flow completes. Nothing leaks, and there is no close() for you to remember.

They never start or close the recipe. Subscribing to cache.eventsAsFlow() does not start the cache, and cancelling the collector does not close it. Lifecycle stays where it was — use { } and start() — and the flow is purely an observer. The one exception is watchAsFlow, which owns its watcher: cancelling the collector closes it, bounded by the watcher's ≤5-second close contract.

They are cold. Each collector gets its own listener and its own buffer. For a hot, shared stream, use .shareIn(scope) / .stateIn(scope).

Buffering protects the producer, not you. This is the important one.

What capacity actually means

Every one of these listeners fires on a thread that must not be blocked: jetcd's watch dispatcher, a recipe's lease-healer thread, or jetcd's lease-callback thread. They push with trySendBlocking, which under the UNLIMITED default never blocks — so a slow collector's backlog grows in memory, and the producing thread sails on.

Setting a bounded capacity opts into backpressure, and the thing that experiences that backpressure is the producer's dispatcher thread:

// A bounded capacity opts INTO backpressure: once 64 elements are pending, the
// watcher's own dispatcher thread parks — which also stalls recovery and resync
// for this watch. Only bound it when a stalled watch beats an unbounded buffer.
client
  .watchEventsAsFlow("/config", watchOption { isPrefix(true) }, capacity = 64)
  .collect { event -> logger.info { "${event.eventType} ${event.keyAsString}" } }

For watchAsFlow that thread also runs recovery and resync, so a full buffer stalls your watch's resubscription, not just its delivery. For a cache it stalls the cache's view of the world, which means currentData goes stale while your collector catches up.

Bound the capacity only if you have decided a stalled producer is better than an unbounded buffer

That is a real trade — an unbounded buffer under a permanently slow collector is a memory leak with extra steps. But it is a trade you should make deliberately, per flow, knowing that the stall reaches further than the events themselves. The usual alternative is to keep UNLIMITED and make the collector fast: hand work off to another coroutine, or .conflate() if you only care about the latest element.

Watches — WatchFlowEvent.kt

watchAsFlow is the flow face of the resilient watcher: stream deaths are recovered per the WatchResilience config, and after a compaction your resyncWith hook is invoked to re-read the world and hand back a new anchor revision.

Elements are a sealed interface, because responses and recovery transitions arrive in-band and in order:

sealed interface WatchFlowEvent {
  data class Response(val response: WatchResponse) : WatchFlowEvent
  data class Recovery(val event: WatchRecoveryEvent) : WatchFlowEvent
}
client
  .watchAsFlow("/config", watchOption { isPrefix(true) })
  .collect { element ->
    when (element) {
      is WatchFlowEvent.Response -> {
        element.response.events.forEach { event ->
          logger.info { "${event.eventType} ${event.keyAsString} = ${event.valueAsString}" }
        }
      }

      // Recovery arrives in-band and in order, so a collector keeping derived
      // state learns about a gap before the events that follow it.
      is WatchFlowEvent.Recovery -> {
        logger.warn { "Watch recovery: ${element.event}" }
      }
    }
  }

Why recovery is in-band

A resilient watch that silently hid suspension, resubscription, and compaction resync would be a trap for any collector that keeps derived state. "The events stopped for nine seconds and then resumed from a different revision" is not a detail the library gets to keep to itself — it is exactly the moment your derived state became a lie. Delivering Recovery in the same ordered stream means you learn about the gap before the events that follow it.

resyncWith is your one chance to reconcile after a compaction: the events between the compacted revision and the new anchor are gone from etcd and are never coming back.

// resyncWith runs after a compaction: re-read your world and return the revision
// to re-anchor at. Events between the compaction point and the anchor are gone —
// this hook is your only chance to reconcile.
val resyncWith = {
  val response = client.getResponse("/config", getOption { isPrefix(true) })
  response.kvs.forEach { kv -> logger.info { "Resync: ${kv.key.asString}" } }
  response.header.revision + 1
}

client
  .watchAsFlow("/config", watchOption { isPrefix(true) }, resyncWith = resyncWith)
  .collect { element ->
    if (element is WatchFlowEvent.Recovery && element.event is WatchRecoveryEvent.Resynced)
      logger.warn { "Reconciled after a compaction" }
  }

watchEventsAsFlow is the convenience form — it flattens responses into their WatchEvents and drops recovery transitions entirely:

// Flattens responses into events and drops recovery transitions. Fine for a
// stateless collector; wrong for one that keeps derived state.
client
  .watchEventsAsFlow("/config", watchOption { isPrefix(true) })
  .collect { event ->
    logger.info { "${event.eventType} ${event.keyAsString}" }
  }

watchEventsAsFlow silently skips the gap

Dropping recovery is fine for a stateless collector — one that reacts to each event and remembers nothing. It is wrong for a collector that maintains derived state, because after a compaction resync this flow just carries on as if no events had been lost. If you keep state, use watchAsFlow.

See Watches for the underlying model and Resilience for the recovery policy.

Leadership — ElectionFlows.kt

sealed interface LeadershipEvent {
  data class Elected(val leaderName: String) : LeadershipEvent
  data object Vacated : LeadershipEvent
  data class WatchFailed(val cause: Throwable?) : LeadershipEvent
}
// Observing, not participating: the current leader is emitted immediately on
// collect, so a late subscriber is never left blind waiting for a hand-off.
client
  .leadershipAsFlow("/elections/orders")
  .collect { event ->
    when (event) {
      is LeadershipEvent.Elected -> logger.info { "Leader is ${event.leaderName}" }
      is LeadershipEvent.Vacated -> logger.warn { "No leader right now" }
      is LeadershipEvent.WatchFailed -> logger.error(event.cause) { "Observation stopped" }
    }
  }

The current leader is emitted immediately on collect

leadershipAsFlow reads the leader key before subscribing its watcher and emits that first, so a collector that starts long after the election was decided is not left blind until the next hand-off — which might be hours away, or never. You get Elected(name) or Vacated straight away, then transitions.

The same re-read happens after every Resubscribed or Resynced, because a hand-off may have happened while the stream was dead. WatchFailed means observation has stopped for good — the recovery policy was exhausted.

This flow observes; it does not participate. To run for election, use LeaderSelector with awaitStart() / awaitLeadershipComplete() — see Leader election.

Caches — CacheFlows.kt

Each cache exposes its events and its watch-recovery transitions as separate flows:

PathChildrenCache(client, "/services").use { cache ->
  // Subscribe BEFORE starting the cache: events fired while nothing collects are
  // not buffered, so a late collector misses INITIALIZED.
  scope.launch {
    cache.eventsAsFlow().collect { event ->
      logger.info { "${event.type} ${event.childName}" }
    }
  }
  scope.launch {
    cache.recoveryEventsAsFlow().collect { event ->
      logger.warn { "Cache watch recovery: $event" }
    }
  }
  cache.start(PathChildrenCache.StartMode.POST_INITIALIZED_EVENT)
}

Subscribe before you start the cache

Events fired while nothing is collecting are not buffered — callbackFlow only registers the listener when collection begins. If you call start(POST_INITIALIZED_EVENT) and then subscribe, the INITIALIZED event is already gone and you will wait forever for an event that has been and passed. Launch the collector first (and give it a moment, or signal from onStart), then start the cache.

NodeCache<T> and TypedPathChildrenCache<T> decode through their codec, so their flows carry typed elements:

NodeCache(client, "/config/mode", StringCodec).use { cache ->
  scope.launch {
    cache.eventsAsFlow().collect { event ->
      logger.info { "${event.type}: ${event.value}" }
    }
  }
  cache.start()
}

See Caches and Typed values.

Service discovery — DiscoveryFlows.kt

ServiceCacheEvent flattens the four-argument ServiceCacheListener callback into one data class:

data class ServiceCacheEvent(
  val eventType: WatchEvent.EventType,
  val isAdd: Boolean,
  val serviceName: String,
  val serviceInstance: ServiceInstance?,
)
ServiceCache(client, "/services/orders/names", "worker").use { cache ->
  scope.launch {
    cache.eventsAsFlow().collect { event ->
      val verb = if (event.isAdd) "joined" else "left"
      logger.info { "${event.serviceInstance?.id} $verb ${event.serviceName}" }
    }
  }
  cache.start()
}

See Service discovery.

Lock and permit loss — LockFlows.kt

data class LockLostEvent(val cause: Throwable?)
data class PermitLostEvent(val cause: Throwable?)
DistributedMutex(client, "/locks/orders").use { mutex ->
  scope.launch {
    // Unbuffered by design: the listener fires on jetcd's lease-callback thread,
    // which must never block, so this flow takes no capacity argument.
    mutex.lockLostAsFlow().collect { event ->
      logger.error(event.cause) { "Lost the lock; abandoning work" }
    }
  }
}

DistributedSemaphore(client, "/semaphores/uploads", permits = 3).use { semaphore ->
  scope.launch {
    semaphore.permitLostAsFlow().collect { event ->
      logger.error(event.cause) { "Lost a permit" }
    }
  }
}

These two take no capacity — deliberately

Every other flow here lets you opt into backpressure. These do not, because their listeners fire on jetcd's lease-callback thread, which must never block: park it and you stop the keep-alives for every lease on that client, which is a spectacular way to turn one lost lock into all of them. The channel is therefore unconditionally unbounded, and no argument is offered that could make it otherwise.

The practical consequence is small — lock loss is rare and one event per loss is not a volume problem — but it is why the signature differs.

Lock loss itself is covered in Locks; the lease mechanics are in Leases and loss.

Lease events — LeaseFlows.kt

The recipes that own a self-healing lease — TransientKeyValue, DistributedWorkQueue, and ServiceRegistry — publish its lifecycle:

TransientKeyValue(client, "/nodes/worker-1", "alive").use { kv ->
  scope.launch {
    kv.leaseEventsAsFlow().collect { event ->
      when (event) {
        is LeaseEvent.Suspended -> logger.warn { "Keep-alive faltering: ${event.cause}" }
        is LeaseEvent.Expired -> logger.error { "Key ownership was lost" }
        is LeaseEvent.Restored -> logger.info { "Healed onto lease ${event.newLeaseId}" }
        is LeaseEvent.Failed -> logger.error(event.cause) { "Healing abandoned" }
      }
    }
  }
}

Suspended → the keep-alive stream hit a transient error. Expired → the lease is gone and ownership of its keys may have moved to someone else. Restored → a replacement lease was granted and the keys re-established. Failed → healing was abandoned. See Leases and loss.

Connection state and background failures

connectionStateAsFlow and backgroundExceptionsAsFlow are on EtcdConnector, so they are available on every stateful recipe:

PathChildrenCache(client, "/services").use { cache ->
  scope.launch {
    // Emits the current state on collect, then every transition. Conflated: a slow
    // collector sees the latest state rather than every intermediate one.
    cache.connectionStateAsFlow().collect { state ->
      if (state == ConnectionState.LOST) logger.error { "Cache is no longer trustworthy" }
    }
  }
  scope.launch {
    // The push counterpart to the pull-only `exceptions` list.
    cache.backgroundExceptionsAsFlow().collect { failure ->
      logger.error(failure.throwable) { "Background failure in ${failure.context}" }
    }
  }
}

connectionStateAsFlow emits the current state on collect — same reasoning as leadershipAsFlow: a late subscriber should not have to wait for a transition to learn where it stands. It is .conflate()d, so a slow collector sees the latest state rather than every intermediate one. That is the right default for state: nobody benefits from processing a stale SUSPENDED after RECONNECTED has already landed. It is also cold — .stateIn(scope) gives you a shared StateFlow.

backgroundExceptionsAsFlow is the push counterpart to the pull-only EtcdConnector.exceptions list. Its listener fires on the reporting recipe's own thread, so it is buffered: a failure is never dropped, and the reporting thread never blocks.

See Connection state.

Composing under structured concurrency

Flows compose the way you would hope: one parent job per subscription set, and cancelling the parent unsubscribes everything — each awaitClose removes its listener or closes its watcher.

PathChildrenCache(client, "/services").use { cache ->
  // One parent job per subscription set: cancelling `collectors` unsubscribes
  // every flow — each awaitClose removes its listener or closes its watcher.
  val collectors =
    scope.launch {
      launch {
        cache.eventsAsFlow().collect { event ->
          logger.info { "Cache: ${event.type} ${event.childName}" }
        }
      }
      launch {
        cache.connectionStateAsFlow().collect { state ->
          logger.info { "Connection state: $state" }
        }
      }
      launch {
        client.leadershipAsFlow("/elections/orders").collect { event ->
          logger.info { "Leadership: $event" }
        }
      }
    }

  cache.start(buildInitial = true)
  collectors.cancel()
}

One coroutine per flow, not one when over a merged stream

merge() works, but it forces a common element type and couples the collectors' failure modes: a slow branch backs up the others' buffers, and an exception in one kills them all. Separate launches under one parent give you the cancellation story you want with none of that.