Skip to content

Transient key/values

TransientKeyValue publishes a key that exists only while your process is alive to say so. It grants a lease, puts the key under it, and keeps the lease renewed in the background. Stop renewing — because you closed the recipe, or because your process died — and etcd removes the key when the TTL runs out.

That is the primitive behind presence: "node-1 is up and reachable at this address" is a claim nobody else can make on your behalf, and one that has to expire on its own when you stop making it.

It extends EtcdConnector, so it shares the lifecycle, exception, and connection-state surface described in Core concepts.

// autoStart defaults to true: by the time the constructor returns, the lease has
// been granted, the key put, and the keep-alive started.
TransientKeyValue(client, "/nodes/node-1", "10.0.0.7:8080", leaseTtlSecs = 5L).use { kv ->
  // The key exists for exactly as long as this recipe is open and renewing.
  logger.info { "Published ${kv.keyPath} = ${kv.keyValue} as ${kv.clientId}" }
}
// close() stops the keep-alive; etcd drops the key when the lease runs out.
// autoStart defaults to true: by the time the constructor returns, the lease has
// been granted, the key put, and the keep-alive started.
try (TransientKeyValue kv =
       new TransientKeyValue(client, "/nodes/node-1", "10.0.0.7:8080", 5L)) {
  // The key exists for exactly as long as this recipe is open and renewing.
  System.out.println("Published " + kv.getKeyPath() + " = " + kv.getKeyValue());
}
// close() stops the keep-alive; etcd drops the key when the lease runs out.

autoStart = true is the exception to the constructor rule

Every other recipe in this library has an RPC-free constructor: you construct it, and nothing touches the network until start(). TransientKeyValue breaks that rule by default.

Constructing a TransientKeyValue starts the keep-alive

With autoStart = true — the default — the constructor grants the lease, puts the key, and starts the renewal loop before it returns. Three consequences worth knowing before you write the line:

  • It needs a live etcd. Constructing one in a unit test, or in a Spring bean graph that assembles before etcd is reachable, will throw. The constructor propagates a setup failure as EtcdRecipeRuntimeException rather than handing you a recipe that looks fine and publishes nothing.
  • You cannot register a lease listener in time. The keep-alive is already running when you get the reference, so addLeaseListener after construction may miss early events.
  • The reference you never received still owns resources. The constructor shuts down its own executor before propagating a startup failure, precisely because you have no object to close().

Pass autoStart = false to get the ordinary deferred-start behaviour that the rest of the library gives you:

// autoStart = false defers every RPC to start(), which is what the rest of the
// library does. It also makes the recipe constructible without a live etcd.
TransientKeyValue(
  client = client,
  keyPath = "/nodes/node-1",
  keyValue = "10.0.0.7:8080",
  leaseTtlSecs = 5L,
  autoStart = false,
).use { kv ->
  // One-shot: a second start() throws EtcdRecipeRuntimeException.
  kv.start()
  logger.info { "Publishing ${kv.keyPath}" }
}
// autoStart = false defers every RPC to start(). Java has no named arguments, so
// leaseTtlSecs must be supplied positionally to reach the autoStart parameter.
try (TransientKeyValue kv =
       new TransientKeyValue(client, "/nodes/node-1", "10.0.0.7:8080", 5L, false)) {
  // One-shot: a second start() throws EtcdRecipeRuntimeException.
  kv.start();
  System.out.println("Publishing " + kv.getKeyPath());
}

start() is @Synchronized and one-shot: a second call throws EtcdRecipeRuntimeException, as does calling it after close(). It blocks until the keep-alive is actually running (or has failed), so when it returns without throwing, the key is published.

The full parameter list is (client, keyPath, keyValue, leaseTtlSecs, autoStart, userExecutor, clientId, resilience). leaseTtlSecs is how long the key survives your silence — the shorter it is, the faster your absence is noticed and the more renewal traffic you generate. clientId defaults to a generated identifier and tags this publisher in the recipe's logs. userExecutor lets you supply the thread the keep-alive parks on; leave it null and the recipe owns a single-thread executor it shuts down on close().

This lease is healed

A lease that confers exclusivity — a lock hold, a semaphore permit, an election's leadership key — is deliberately never healed: if it expired, etcd already promoted someone else, and re-granting would mean two holders. A transient key/value has no such problem — publishing your own address is not an exclusive claim, and nobody else is competing to be you — so this lease is self-healed.

See Leases and loss for the full split, including why a single LeaderSelector sits on both sides of it.

TransientKeyValue(
  client = client,
  keyPath = "/nodes/node-1",
  keyValue = "10.0.0.7:8080",
  leaseTtlSecs = 5L,
  autoStart = false,
).use { kv ->
  // Unlike a lock or election lease, this one IS healed: on expiry the recipe
  // re-grants a lease and re-puts the key. Publishing your own address has no
  // exclusive ownership for a re-put to race against.
  kv.addLeaseListener { event ->
    when (event) {
      is LeaseEvent.Suspended -> logger.warn { "Renewal hiccup on lease ${event.leaseId}" }

      is LeaseEvent.Expired -> logger.warn { "Lease ${event.leaseId} expired; healing" }

      is LeaseEvent.Restored -> logger.info { "Healed: ${event.oldLeaseId} -> ${event.newLeaseId}" }

      // Healing gave up. The key is gone and stays gone.
      is LeaseEvent.Failed -> logger.error { "Healing abandoned for ${event.leaseId}" }
    }
  }
  // Registering before start() is only possible with autoStart = false.
  kv.start()
}
try (TransientKeyValue kv =
       new TransientKeyValue(client, "/nodes/node-1", "10.0.0.7:8080", 5L, false)) {
  // Unlike a lock or election lease, this one IS healed: on expiry the recipe
  // re-grants a lease and re-puts the key. Registering the listener before
  // start() is only possible with autoStart = false.
  kv.addLeaseListener(event -> {
    if (event instanceof LeaseEvent.Restored restored) {
      System.out.println("Healed: " + restored.getOldLeaseId() + " -> " + restored.getNewLeaseId());
    } else if (event instanceof LeaseEvent.Failed) {
      // Healing gave up. The key is gone and stays gone.
      System.out.println("Healing abandoned");
    } else {
      System.out.println("Lease event: " + event);
    }
  });
  kv.start();
}

If a partition outlasts the TTL, the key vanishes from etcd and the healer re-grants a lease and re-puts it. Without that, the recipe would sit there looking healthy while publishing nothing — the single worst failure mode a presence primitive can have.

The LeaseEvents tell you which of those happened:

Event Meaning
Suspended The keep-alive stream hit a transient error; jetcd is retrying it. The lease is still alive.
Expired The lease is gone and the key with it. Healing starts.
Restored A replacement lease was granted and the key re-published.
Failed Healing was abandoned. The key is gone and stays gone.

All four drive connectionState, and the three that mean trouble — Suspended, Expired, Failed — are also recorded on exceptions, so a caller polling exceptions sees renewal problems without registering anything. (Restored is good news, so it is logged rather than recorded.) Failed is the one to alert on. See Leases and loss.

Healing is not free of consequence

Between Expired and Restored the key genuinely does not exist. Anything watching your prefix — a PathChildrenCache, a service discovery cache — sees a removal followed by an addition, not a hiccup. If that flap matters to your consumers, a longer TTL costs you slower failure detection and buys you fewer false removals.

Set resilience.lease to a LeaseResilience with RetryPolicy.never to disable healing and restore the pre-0.12 behaviour, where an expired lease meant the key was gone for good:

// withTransientKeyValue does not expose resilience; construct directly for that.
TransientKeyValue(
  client = client,
  keyPath = "/nodes/node-1",
  keyValue = "10.0.0.7:8080",
  leaseTtlSecs = 5L,
  autoStart = false,
  // Healing off: an expired lease now means the key is gone for good, and you
  // find out from a LeaseEvent.Failed rather than a re-put.
  resilience = ResilienceConfig.DEFAULT.copy(lease = LeaseResilience.DISABLED),
).use { kv ->
  kv.start()
  logger.info { "Publishing ${kv.keyPath}" }
}

Scoped use

withTransientKeyValue(client, "/nodes/node-1", "10.0.0.7:8080", leaseTtlSecs = 5L) {
  logger.info { "Registered at $keyPath for the duration of this block" }
}

The key is published for the duration of the block and dropped on the way out.

withTransientKeyValue does not expose resilience

Its parameters are (client, keyPath, keyValue, leaseTtlSecs, autoStart, userExecutor, clientId, receiver). Construct TransientKeyValue directly when you need a non-default ResilienceConfig.

TypedTransientKeyValue<T>

Publish a typed payload instead of a hand-marshalled String:

// The value is encoded ONCE, at construction, and published under the lease. It is
// immutable for the recipe's lifetime, so the codec must produce UTF-8 text.
TypedTransientKeyValue(
  client = client,
  keyPath = "/nodes/node-1",
  value = NodeInfo("10.0.0.7", 8080),
  codec = jsonCodec<NodeInfo>(),
  leaseTtlSecs = 5L,
).use { kv ->
  // A Closeable decorator, not an EtcdConnector: exceptions, isHealthy() and the
  // rest of the connector surface live on `untyped`.
  logger.info { "Healthy: ${kv.untyped.isHealthy()}, published ${kv.untyped.keyValue}" }
}

The value is encoded once, at construction, and that encoded string is what the keep-alive re-publishes for the recipe's lifetime — including across healing. There is no setter: a transient key/value is a standing claim, not a mutable cell. To publish something new, close this one and construct another.

Because the underlying recipe publishes a String, the codec must produce UTF-8 text: StringCodec, jsonCodec<T>(), or the Jackson codec from etcd-recipes-jackson. A binary codec will not do. Read it back with the typed getValue(key, codec). See Typed values.

It is a decorator, not a connector

TypedTransientKeyValue<T> implements Closeable and does not extend EtcdConnector. exceptions, isHealthy(), connectionState, ping() and the rest of the connector surface live on untyped, which is a public property and the documented escape hatch. start, addLeaseListener and close are delegated directly, so the common path needs no unwrapping.

withTypedTransientKeyValue(client, "/nodes/node-1", NodeInfo("10.0.0.7", 8080), jsonCodec<NodeInfo>()) {
  logger.info { "Registered as ${untyped.keyValue}" }
}

withTypedTransientKeyValue — unlike its untyped counterpart — does take a resilience argument.

Coroutines

leaseEventsAsFlow() exposes the lease lifecycle as a Flow<LeaseEvent> instead of a listener. Collecting registers a listener and cancelling removes it; it never starts or closes the recipe. See Flows.

If you are publishing a key so that other services can find you, look at Service discovery first. It solves the same presence problem with the same self-healing lease machinery, and adds the registry, the caches, and the provider strategies you would otherwise write on top of this. Reach for TransientKeyValue directly when you want presence without the service model.