Skip to content

Integrations

The library is deliberately small at its centre. etcd-recipes-core has the recipes and nothing else; everything that would drag in a framework lives in its own artifact, so you pull in Jackson only if you want Jackson, and Micrometer only if you want Micrometer.

Artifacts

All published to Maven Central under the group com.pambrose, at version 0.12.0.

Artifact What it gives you
etcd-recipes-core The library: locks, elections, barriers, queues, caches, counters, service discovery, and the jetcd extension layer. Everything else depends on this.
etcd-recipes-jackson JacksonCodec<T> — a Jackson-backed EtcdCodec for Java callers and anyone who would rather not use kotlinx-serialization. See Jackson.
etcd-recipes-micrometer MicrometerEtcdMetrics (the push metrics backend) and the EtcdGauges binders. See below.
etcd-recipes-spring-boot-starter Auto-configures a Client, an EtcdRecipes factory, and an Actuator health indicator from application.yml. See Spring Boot.
etcd-recipes-ktor An application plugin that owns an etcd connection for the server's lifetime. See Ktor.
dependencies {
  implementation("com.pambrose:etcd-recipes-core:0.12.0")

  // Optional, pick what you need:
  implementation("com.pambrose:etcd-recipes-jackson:0.12.0")
  implementation("com.pambrose:etcd-recipes-micrometer:0.12.0")
  implementation("com.pambrose:etcd-recipes-spring-boot-starter:0.12.0")
  implementation("com.pambrose:etcd-recipes-ktor:0.12.0")
}
<dependency>
  <groupId>com.pambrose</groupId>
  <artifactId>etcd-recipes-core</artifactId>
  <version>0.12.0</version>
</dependency>
<!-- Optional; each brings etcd-recipes-core transitively. -->
<dependency>
  <groupId>com.pambrose</groupId>
  <artifactId>etcd-recipes-jackson</artifactId>
  <version>0.12.0</version>
</dependency>

Each satellite depends on core, so naming a satellite is enough — you do not need to list core as well.

The framework versions each satellite is built against:

Artifact Built against
etcd-recipes-jackson Jackson 2.22.x
etcd-recipes-micrometer Micrometer 1.17.x
etcd-recipes-spring-boot-starter Spring Boot 4.1.x
etcd-recipes-ktor Ktor 3.5.x

Each is an api dependency of its own module only — the core artifact pulls in none of them, so a project that wants no Micrometer never sees Micrometer on its classpath.

EtcdConnectionConfig

The framework modules exist because of one shared problem: a framework needs to build the client for you, from configuration, without a Kotlin lambda in the middle. EtcdConnectionConfig is the declarative answer, and both the starter and the plugin bind their own config onto it:

data class EtcdConnectionConfig(
  val endpoints: List<String>,
  val user: String? = null,
  val password: String? = null,
  val namespace: String? = null,
  val connectTimeout: Duration = Duration.ofSeconds(5),
  val retryMaxDuration: Duration = Duration.ofSeconds(30),
  val tls: EtcdTlsConfig? = null,
)

data class EtcdTlsConfig(
  val caCertPath: String? = null,
  val clientCertPath: String? = null,
  val clientKeyPath: String? = null,
)

Pass it to connectToEtcd(config). In EtcdTlsConfig, caCertPath sets the trust manager (server verification) and clientCertPath + clientKeyPath together enable mutual TLS.

These are java.time.Duration, not kotlin.time.Duration

connectTimeout and retryMaxDuration map straight onto jetcd's ClientBuilder, which is a Java API — so they are java.time.Duration and want Duration.ofSeconds(5), not 5.seconds. This is the one place in the library where that is true; the recipes themselves take kotlin.time.Duration. If you have a kotlin.time.Duration in hand, .toJavaDuration() converts it.

Connecting covers connections properly — endpoints, auth, namespacing, TLS, and the initReceiver escape hatch for raw jetcd builder options.

EtcdRecipes

EtcdRecipes is a thin factory: wire the connection once, then ask it for path-scoped recipes bound to that client (and to a shared ResilienceConfig). Both framework modules contribute one, but it works standalone — EtcdRecipes(client) is the whole setup.

class EtcdRecipes(client: Client, resilience: ResilienceConfig = ResilienceConfig.DEFAULT) {
  fun mutex(lockPath: String): DistributedMutex
  fun readWriteLock(lockPath: String): DistributedReadWriteLock
  fun semaphore(semaphorePath: String, permits: Int): DistributedSemaphore
  fun distributedQueue(queuePath: String): DistributedQueue
  fun distributedPriorityQueue(queuePath: String, minimumWaitTime: Duration = 0.milliseconds): DistributedPriorityQueue
  fun leaderLatch(electionPath: String): LeaderLatch
  fun pathChildrenCache(cachePath: String): PathChildrenCache
  fun <T> nodeCache(key: String, codec: EtcdCodec<T>): NodeCache<T>
  fun serviceDiscovery(servicePath: String): ServiceDiscovery
  fun distributedAtomicLong(counterPath: String): DistributedAtomicLong
}

Every method just constructs the recipe. The factory does not own what it builds — it does not start it, does not close it, and does not track it. The start()/close() lifecycle stays yours, exactly as if you had called the constructor.

The factory is a convenience, not the API

It covers the recipes whose construction is (client, path) and little else. There is deliberately no workQueue, leaderSelector, barrier, or leaderObserver factory method: those take listeners, callbacks, or participant counts that a one-string-argument factory cannot express without becoming a worse constructor. Construct them directly — nothing about EtcdRecipes is privileged, and mixing the two styles in one application is fine.

Micrometer

etcd-recipes-micrometer bridges the library's metrics SPI to a Micrometer MeterRegistry. There are two halves, and they work differently.

Push: MicrometerEtcdMetrics

MicrometerEtcdMetrics(registry) implements core's EtcdMetrics interface. Install it on a ResilienceConfig and every RPC, watch recovery, lease keep-alive, lock wait, election transition, queue operation, and cache sync in that config's blast radius reports itself:

// withMetrics() returns a copy that routes every RPC, watch, and lease funnel — plus the
// recipe-level seams (lock waits, elections, queue ops, cache syncs) — to the registry.
val resilience = ResilienceConfig.DEFAULT.withMetrics(MicrometerEtcdMetrics(registry))

DistributedMutex(client, "/locks/orders", resilience = resilience).use { mutex ->
  mutex.withLock { logger.info { "Timed as etcd.lock.wait and etcd.lock.hold" } }
}

withMetrics() returns a copy — ResilienceConfig is an immutable data class — so build it once and hand it to everything. The EtcdRecipes factory is a good place to put it:

// Hand the instrumented config to the EtcdRecipes factory once and every recipe it
// builds reports, rather than remembering to pass `resilience` at each construction.
val resilience = ResilienceConfig.DEFAULT.withMetrics(MicrometerEtcdMetrics(registry))
return EtcdRecipes(client, resilience)

Pull: the EtcdGauges binders

Gauges are the other shape. A push meter fires when something happens; a gauge is polled on every scrape, so it needs a live instance to poll. Bind one to a recipe you already hold:

Binder Meter Source
bindQueueDepth(queue, tags) etcd.queue.depth AbstractQueue.size
bindCacheSize(cache, tags) etcd.cache.entries PathChildrenCache.currentData.size
bindServiceCacheSize(cache, tags) etcd.cache.entries ServiceCache.instances.size
bindAvailablePermits(semaphore, tags) etcd.semaphore.available DistributedSemaphore.availablePermits()
bindLeadership(latch, tags) etcd.election.leader LeaderLatch.hasLeadership (1.0 / 0.0)
// Gauges are pull-based: bind one to an instance you already hold and it polls that
// instance on every scrape. Micrometer keeps only a weak reference, so binding a gauge
// does not keep the recipe alive.
DistributedQueue(client, "/queues/orders").use { queue ->
  registry.bindQueueDepth(queue, Tags.of("queue", "orders"))
}

LeaderLatch(client, "/election/orders").use { latch ->
  latch.start()
  // 1.0 while this instance is the leader, 0.0 otherwise. An in-memory read; no RPC.
  registry.bindLeadership(latch, Tags.of("election", "orders"))
}

Micrometer holds only a weak reference to the bound instance, so a gauge never keeps a recipe alive. Binding several instances of the same gauge to one registry needs distinguishing tags, or they will collide.

Two of these gauges hit etcd on every scrape

bindQueueDepth and bindAvailablePermits poll accessors (AbstractQueue.size, availablePermits()) that issue a range-count RPC. At a 15-second scrape interval against a handful of queues that is nothing; across hundreds of bound instances, or at a 1-second interval, it is real load on the cluster. The other three are in-memory reads and cost nothing.

// Mind the ones that cost an RPC. availablePermits() (like AbstractQueue.size) issues a
// range-count against etcd, so this gauge hits the cluster on EVERY scrape.
DistributedSemaphore(client, "/semaphores/pool", 5).use { semaphore ->
  registry.bindAvailablePermits(semaphore, Tags.of("pool", "workers"))
}

Kotlin-only

The binders are Kotlin extension functions on MeterRegistry with default arguments and no @JvmOverloads, so they are awkward from Java (EtcdGaugesKt.bindQueueDepth(registry, queue, Tags.empty())). MicrometerEtcdMetrics itself is an ordinary class and works fine from Java.

Observability has the full meter catalog — every name, tag, and what it means.