Skip to content

Leases

A lease is a countdown timer that lives on the etcd server. It is granted with a TTL, it expires unless something renews it, and any key bound to it is deleted by etcd the moment it does.

That last clause is the whole point, and it is worth being precise about why it matters: the deletion is performed by the cluster, not by your process. Your process does not have to notice that it has died. It does not have to be reachable, or healthy, or willing. A machine that loses power, a JVM stopped at a breakpoint, a container the scheduler killed without warning — all of them release their leased keys on schedule, because nothing they do or fail to do is involved.

This is the mechanism under every ephemeral recipe in the library. A service registration that disappears when the service dies, a lock that cannot be held forever by a crashed holder, a barrier participant that stops counting when its process goes away — all of them are a lease and a key bound to it.

Granting and binding

// A lease is a timer that lives on the etcd server, not in your process. Bind a key to
// it and etcd deletes that key the instant the lease expires — no client needs to be
// alive, reachable, or willing for that to happen.
val lease = client.leaseGrant(5.seconds)
client.putValue("/services/worker-1", "10.0.0.1:8080", putOption { withLeaseId(lease.id) })

// Nothing is renewing it, so the key is gone roughly 5 seconds from now.
logger.info { "Lease ${lease.id} granted with a ${lease.ttl}s TTL" }
// A lease is a timer that lives on the etcd server, not in your process. Bind a key to
// it and etcd deletes that key the instant the lease expires — no client needs to be
// alive, reachable, or willing for that to happen.
//
// LeaseUtils.leaseGrant takes a kotlin.time.Duration, an inline value class, so its
// JVM name is mangled and Java cannot call it. Grant through jetcd's lease client.
LeaseGrantResponse lease = client.getLeaseClient().grant(5).get();
putValue(client, "/services/worker-1", "10.0.0.1:8080",
  putOption(builder -> builder.withLeaseId(lease.getID())));

// Nothing is renewing it, so the key is gone roughly 5 seconds from now.
System.out.printf("Lease %d granted with a %ds TTL%n", lease.getID(), lease.getTTL());

leaseGrant returns jetcd's LeaseGrantResponse; its id is what binds a key to it, via putOption { withLeaseId(lease.id) }. Many keys can share one lease, which is how a multi-key registration is made to appear and vanish as a unit.

Java cannot call leaseGrant

leaseGrant takes a kotlin.time.Duration, which is an inline value class. Kotlin mangles the JVM name of any function with a value-class parameter — leaseGrant compiles to leaseGrant-HG0u8IE, and - is not a legal character in a Java identifier. There is no way to call it from Java, and no @JvmName rescues it.

Java callers grant through jetcd directly — client.getLeaseClient().grant(5).get() — and then use the rest of the layer normally, as the Java tab above shows. The LeaseUtils functions that take no Duration (keepAlive, keepAliveWith, leaseRevoke) are all perfectly callable, as are the ttlSecs: Long overloads in KeepAliveUtils. See the Java guide.

The TTL is a floor, not a promise

etcd may keep a lease slightly beyond its TTL — TTLs are enforced at cluster granularity, and etcd will not expire a lease while it cannot reach consensus. So a TTL of 5 seconds means "not before 5 seconds", never "at exactly 5 seconds". Never build a protocol that needs a key gone by a specific instant. Very short TTLs are also a bad trade: they multiply renewal traffic and make a brief GC pause look like death.

Keeping a lease alive

A granted lease with nobody renewing it is a dead man's switch that has already been let go. Renewal is what makes a lease mean "this process is still here".

val lease = client.leaseGrant(5.seconds)
client.putValue("/services/worker-1", "10.0.0.1:8080", putOption { withLeaseId(lease.id) })

// keepAliveWith renews in the background for exactly as long as the block runs, then
// stops. onKeepAliveError fires if the renewal stream dies while you are still inside
// — without it, renewal can stop silently and the key vanishes while you look healthy.
client.keepAliveWith(lease, onKeepAliveError = { e -> logger.error(e) { "Renewal stopped" } }) {
  logger.info { "Registered for the duration of this block" }
}

// Best-effort by design: a revoke that fails is logged, not thrown, because the TTL is
// already the upper bound on how long the lease can outlive you.
client.leaseRevoke(lease)
LeaseGrantResponse lease = client.getLeaseClient().grant(5).get();
putValue(client, "/services/worker-1", "10.0.0.1:8080",
  putOption(builder -> builder.withLeaseId(lease.getID())));

// keepAliveWith renews in the background for exactly as long as the block runs, then
// stops. Java has no default arguments, so the onKeepAliveError callback is not
// optional here — which is no loss: renewal can otherwise stop silently and the key
// vanishes while the process still looks healthy.
keepAliveWith(client, lease,
  error -> {
    System.out.println("Renewal stopped: " + error);
    return Unit.INSTANCE;
  },
  () -> {
    System.out.println("Registered for the duration of this block");
    return Unit.INSTANCE;
  });

// Best-effort by design: a revoke that fails is logged, not thrown, because the TTL is
// already the upper bound on how long the lease can outlive you.
leaseRevoke(client, lease);

keepAliveWith starts renewal, runs your block, and stops renewing when the block exits — however it exits. The lease then expires on its TTL, and the keys bound to it go with it.

onKeepAliveError is the parameter to care about. jetcd's own observer leaves the renewal stream's onError and onCompleted as no-ops, which produces the worst failure this library can have: renewal stops, your keys expire, and your process carries on looking completely healthy. So the layer logs both at error/warn and hands them to onKeepAliveError, and onCompleted synthesizes a throwable so a stream that merely stops is reported like one that broke. Neither callback fires on your own close() — if you hear from it, renewal genuinely stopped.

Supply it. A lease whose renewal has died is not a problem you want to learn about from a downstream service's error rate.

When the lease must outlive the block

val lease = client.leaseGrant(5.seconds)

// The unscoped form, when the lease must outlive the current block. You own the close:
// renewal continues until the returned CloseableClient is closed.
val renewal = client.keepAlive(lease) { e -> logger.error(e) { "Renewal stopped" } }
renewal.use {
  logger.info { "Renewing lease ${lease.id}" }
}
LeaseGrantResponse lease = client.getLeaseClient().grant(5).get();

// The unscoped form, when the lease must outlive the current block. You own the close:
// renewal continues until the returned CloseableClient is closed.
try (CloseableClient renewal = keepAlive(client, lease)) {
  System.out.println("Renewing lease " + lease.getID());
}

keepAlive returns a CloseableClient and hands you the lifetime. Renewal runs until you close it. Prefer keepAliveWith unless the scopes genuinely do not nest.

Revoking

leaseRevoke expires a lease immediately rather than waiting out its TTL — the polite way to deregister, since it deletes your keys now instead of leaving a tombstone that other clients believe in for another TTL.

It is best-effort by design: failures are logged and swallowed. Callers use it on cleanup paths — a failed CAS, an exception on the way out — where raising a secondary failure would mask the original problem. If the revoke RPC itself fails, the TTL is already the upper bound on how long the lease can linger, so there is nothing a thrown exception would let you usefully do.

Put, renew, and revoke in one call

The grant/put/renew/revoke sequence is the same every time, so KeepAliveUtils collapses it:

// Grant, put, renew, and revoke collapsed into one call: the key lives exactly as long
// as the block. If a put throws on the way in, the lease is revoked rather than
// stranded for its TTL. This is the shape every ephemeral recipe is built from.
client.putValueWithKeepAlive("/services/worker-1", "10.0.0.1:8080", 5L) {
  logger.info { "Serving; the registration key is renewed underneath us" }
}
// Grant, put, renew, and revoke collapsed into one call: the key lives exactly as long
// as the block. If a put throws on the way in, the lease is revoked rather than
// stranded for its TTL. This is the shape every ephemeral recipe is built from.
putValueWithKeepAlive(client, "/services/worker-1", "10.0.0.1:8080", 5L, () -> {
  System.out.println("Serving; the registration key is renewed underneath us");
  return Unit.INSTANCE;
});

The key exists for exactly the duration of the block. putValueWithKeepAlive has eight overloads — String, Int, Long, and ByteSequence values, each with a ttlSecs: Long or a kotlin.time.Duration.

Only the ttlSecs: Long overloads are callable from Java

The Duration ones are disambiguated with @JvmName("putValueWithKeepAliveDur") rather than mangled, so they appear on KeepAliveUtils — but their Duration parameter arrives as a raw long of internal representation bits, which is not something a Java caller can construct meaningfully. Use the ttlSecs overloads, which is what the Java tab does.

A stranded lease is a leak

Granting a lease and then throwing before renewal starts leaves it alive on the server for its whole TTL, holding your keys up. putValuesWithKeepAlive revokes the lease if any put throws on the way in, which is the bug this shape exists to prevent. Hand-rolled grant-then-put sequences should do the same.

Several keys, one lease

// Several keys on ONE lease, so they appear together and vanish together. A reader can
// never catch half a registration published and half of it expired.
val kvs =
  [
    "/services/worker-1/host" to "10.0.0.1".asByteSequence,
    "/services/worker-1/port" to 8080.asByteSequence,
  ]

client.putValuesWithKeepAlive(kvs, 5L, onKeepAliveError = { e -> logger.error(e) { "Renewal stopped" } }) {
  logger.info { "Both keys are alive for exactly this block" }
}
// Several keys on ONE lease, so they appear together and vanish together. A reader can
// never catch half a registration published and half of it expired.
List<Pair<String, ByteSequence>> kvs =
  List.of(
    new Pair<>("/services/worker-1/host", getAsByteSequence("10.0.0.1")),
    new Pair<>("/services/worker-1/port", getAsByteSequence(8080)));

putValuesWithKeepAlive(client, kvs, 5L,
  error -> {
    System.out.println("Renewal stopped: " + error);
    return Unit.INSTANCE;
  },
  () -> {
    System.out.println("Both keys are alive for exactly this block");
    return Unit.INSTANCE;
  });

putValuesWithKeepAlive binds every key in the collection to a single lease, so they expire together. A reader can never catch half a registration published and half of it expired — which is why ServiceDiscovery registers this way rather than with a lease per key.

The puts themselves are separate RPCs, so the keys do not appear atomically. If readers must never see a partial registration on the way in, publish a single key with a typed value instead, or gate visibility behind one final key.

What happens when a lease expires

Nothing, from your side. That is the design and also the difficulty: etcd deletes the keys and your process is not told. The recipes that hold leases layer detection on top — onKeepAliveError here, LockLostListener and connectionState on the recipes — because the lease mechanism itself offers no notification.

Which leads to the question this page deliberately does not answer: should a lease that dies be healed and re-established, or is it gone for good? The answer is not the same for every recipe, and getting it wrong in either direction is a correctness bug. Reviving a lock's lease would race the holder etcd has already promoted; refusing to revive a service registration's lease would silently deregister a healthy service forever.

That split — which leases self-heal, which are never healed, and why — is owned by Leases and loss. Read it before building anything on top of these primitives.