Skip to content

Contributing

Source: github.com/pambrose/etcd-recipes. Licensed Apache 2.0.

Building

JDK 17 and the Gradle wrapper. make help lists every target.

make build      # ./gradlew clean build -x test
make tests      # full suite against a local etcd at localhost:2379
make tests-tc   # full suite against an ephemeral Testcontainers etcd (needs Docker)
make all-tests  # local, Testcontainers and multi-container variants in sequence
make lint       # ./gradlew lintKotlin detekt
make coverage   # Kover HTML + XML + summary
make kdocs      # Dokka API docs

make tests expects an etcd at http://localhost:2379:

./etcd-start.sh  # start          (or: make etcd-start)
./etcd-stop.sh   # graceful shutdown — SIGTERM, then SIGKILL after 10s

A single test class:

./gradlew :etcd-recipes-core:test --tests "io.etcd.recipes.barrier.DistributedBarrierTests"

Modules

Module Published as
etcd-recipes-core com.pambrose:etcd-recipes-core
etcd-recipes-jackson com.pambrose:etcd-recipes-jackson
etcd-recipes-micrometer com.pambrose:etcd-recipes-micrometer
etcd-recipes-spring-boot-starter com.pambrose:etcd-recipes-spring-boot-starter
etcd-recipes-ktor com.pambrose:etcd-recipes-ktor
etcd-recipes-examples — runnable examples
etcd-recipes-test-runners — test-only shadow JAR

Tests

Thread-based (*Tests.kt beside each recipe) — N threads in one JVM simulate distributed clients via blockingThreads(...) / nonblockingThreads(...) from TestExtensions.kt.

Container-based (container/Container*Test.kt) — each participant runs in its own container against a shared etcd container. Gated by assumeTrue(testcontainers=true), so a default ./gradlew check skips them.

Test JVMs fork per class (forkEvery = 1) so one spec's background threads and watch connections cannot bleed into the next. maxParallelForks lets classes run concurrently against one etcd, so each test namespaces its keys by class name. Keep doing that.

New Kotlin tests use Kotest with StringSpec() and an init {} block, plus MockK where it helps.

Adding a recipe

  1. Compose the existing extensions in common/ rather than reaching into jetcd directly.
  2. Extend EtcdConnector for anything stateful — it gives you the lifecycle, the exception list, connection state and close().
  3. Raise EtcdRecipeException / EtcdRecipeRuntimeException, not jetcd types.
  4. Add Kotest tests under etcd-recipes-core/src/test/kotlin/.
  5. Add a runnable example under etcd-recipes-examples/.
  6. Document it here, with a compiled snippet (below).

Documentation

This site is Zensical, under website/.

make site          # serve locally (wipes the previous build first)
make docs-check    # compile the snippets, then build in strict mode — what CI runs
make clean-site    # remove the generated site/ and .cache/
make check-site    # report outdated website dependencies (dry run)
make upgrade-site  # upgrade website dependencies, rewriting uv.lock

make site serves the site; make docs-check is the one that builds it, and it is what you want before pushing.

Code examples are compiled, not pasted

No example on this site is typed into a Markdown file. Every one is a real source file in a Gradle test source set, embedded at build time by pymdownx.snippets. ./gradlew compileTestKotlin compileTestJava type-checks all of them against the real API on every CI run, so an example cannot silently rot when the library changes.

They live in the module whose API they document:

etcd-recipes-core/src/test/kotlin/website/      Kotlin
etcd-recipes-core/src/test/java/website/        Java
etcd-recipes-<satellite>/src/test/…/website/<module>/

Why the satellites need their own

etcd-recipes-core's test source set cannot see the satellite modules — they depend on core, not the reverse. So Spring, Ktor, Jackson and Micrometer examples live in their own module's src/test, and zensical.toml lists each one in base_path.

Adding an example

Write a plain function that is never called, and mark the interesting part:

fun basicMutex(client: Client) {
  DistributedMutex(client, "/locks/orders").use { mutex ->
    mutex.withLock { /* ... */ }
  }
}

Then embed it:

=== "Kotlin"

    ```kotlin
    DistributedMutex(client, "/locks/orders").use { mutex ->
      mutex.withLock {
        // Only one client across the cluster runs this at a time.
        logger.info { "Critical section" }
      }
    }
    ```

=== "Java"

    ```java
    try (DistributedMutex mutex = new DistributedMutex(client, "/locks/orders")) {
      mutex.lock();
      try {
        // Only one client across the cluster runs this at a time.
        System.out.println("Critical section");
      } finally {
        mutex.unlock();
      }
    }
    ```

Run make docs-check and you are done.

Rules

  • They are not tests. No Kotest specs, no assertions, no main(). Plain functions, never invoked. They exist to be compiled.
  • Section names must match [a-z][-_0-9a-z]* — lowercase, digits, -, _. try-lock works; tryLock will not match, and the section silently will not render.
  • kotlinter and detekt lint them like any other source. Keep them small.
  • Prefer showing both languages. Coroutines are the honest exception.
  • A dangling snippet reference fails the build (check_paths = true) rather than rendering an empty block. That is deliberate — please keep it that way.

zensical must run from website/etcd-recipes

pymdownx.snippets resolves base_path against the process working directory, not against zensical.toml's location, so zensical build -f website/etcd-recipes/zensical.toml from the repo root finds no snippets. Every make target cds first.

Versioning

The library version lives in gradle.properties; Kotlin and dependency versions in gradle/libs.versions.toml. All modules share the one version.

Releasing bumps gradle.properties, moves the CHANGELOG.md [Unreleased] section under the new heading, adds a RELEASE_NOTES.md entry, and updates the coordinates quoted in the docs — the README download snippets plus several pages on this site. They are all literal version strings, so:

git grep '0\.11\.0'   # find every reference to the outgoing version

is the reliable way to catch them; do not rely on the list of files staying accurate.

CI

.github/workflows/ci.yml runs detekt, then check koverXmlReport -PuseTestcontainers.

check and koverXmlReport must stay in one Gradle invocation

Splitting them changes the -PuseTestcontainers input, which invalidates the test cache and makes the rerun hang against localhost:2379. The comments in ci.yml record this and the codecov report-path quirk — read them before editing.

.github/workflows/docs.yml builds the site on every PR and deploys from master.