Transactions¶
An etcd transaction is not a database transaction. There is no BEGIN, no open session, no
locks held across round trips, and nothing to roll back. It is a single atomic if/then/else,
sent as one message and evaluated by the server:
If (a list of comparisons, ANDed together)
Then (ops to run if every comparison held)
Else (ops to run if any comparison failed)
One round trip. Either the Then ops all apply or the Else ops all apply, at a single
revision, with no other write interleaved. That is the entire model — and it is enough to build
every lock, counter, and election in this library.
TxnUtils supplies the transaction { } builder plus the comparison and operation helpers.
The basic shape¶
// An etcd transaction is one atomic server-side if/then/else: evaluate the compares,
// then run either the Then ops or the Else ops. One round trip, no lock required.
val response =
client.transaction {
If("/config/name".doesExist)
Then("/audit/last" setTo "found")
Else("/audit/last" setTo "missing")
}
// isSucceeded reports which branch ran — whether the compares held, not whether the
// RPC worked. A failed RPC throws.
logger.info { "Compares held: ${response.isSucceeded}" }
// An etcd transaction is one atomic server-side if/then/else: evaluate the compares,
// then run either the Then ops or the Else ops. One round trip, no lock required.
TxnResponse response = transaction(client, txn -> {
txn.If(getDoesExist("/config/name"));
txn.Then(setTo("/audit/last", "found"));
txn.Else(setTo("/audit/last", "missing"));
return txn;
});
// isSucceeded reports which branch ran — whether the compares held, not whether the
// RPC worked. A failed RPC throws.
System.out.println("Compares held: " + response.isSucceeded());
isSucceeded tells you which branch ran — whether the comparisons held — not whether the
call worked. A transaction whose comparisons fail is a perfectly successful RPC that returns
isSucceeded = false and runs your Else. A transaction that fails as an RPC throws.
Both Then and Else are optional. A transaction with only an If is a pure test, which is
exactly how isKeyPresent is implemented.
Comparisons¶
// The first argument is the KEY being compared, not a value. CmpTarget chooses which
// of that key's fields the comparison reads: version, createRevision, modRevision,
// or value. Several compares in one If() are ANDed together.
client.transaction {
If(
equalTo("/config/name", CmpTarget.value("orders-service".asByteSequence)),
greaterThan("/config/name", CmpTarget.version(0)),
lessThan("/config/name", CmpTarget.modRevision(500L)),
)
Then("/audit/last" setTo "all three held")
}
// The first argument is the KEY being compared, not a value. CmpTarget chooses which
// of that key's fields the comparison reads: version, createRevision, modRevision,
// or value. Several compares in one If() are ANDed together.
transaction(client, txn -> {
txn.If(
equalTo("/config/name", CmpTarget.value(getAsByteSequence("orders-service"))),
greaterThan("/config/name", CmpTarget.version(0)),
lessThan("/config/name", CmpTarget.modRevision(500L)));
txn.Then(setTo("/audit/last", "all three held"));
return txn;
});
The first argument is the key, not a value
equalTo("/config/name", CmpTarget.value(...)) reads as though it compares the string
/config/name. It does not. The first argument names the key being examined; the
CmpTarget says which of that key's fields to read and what to compare it against. The
String, Int, and Long overloads exist only so you can name a key without spelling out
asByteSequence — they never mean "compare against this value".
Three comparison operators, four targets:
| Helper | Operator |
|---|---|
equalTo(key, target) |
= |
lessThan(key, target) |
< |
greaterThan(key, target) |
> |
CmpTarget |
Reads | Useful for |
|---|---|---|
version(n) |
Number of writes since creation; 0 means the key does not exist | Existence |
createRevision(n) |
Revision the key was created at | Ordering, "am I first?" |
modRevision(n) |
Revision of the last write | Compare-and-swap |
value(bytes) |
The value itself | Guarding on content |
Several comparisons in a single If() are ANDed — every one must hold for Then to run.
There is no OR; express alternatives as separate transactions, or restructure so the Else
branch carries the other case.
Create-if-absent¶
// doesExist / doesNotExist are sugar over the key's version: a key that was never
// created — or has since been deleted — has version 0.
val claimed =
client
.transaction {
If("/locks/leader".doesNotExist)
Then("/locks/leader" setTo "node-1")
}.isSucceeded
// This is create-if-absent as ONE atomic step. A get-then-put would race: two clients
// could both read "absent" and both write.
logger.info { "Claimed leadership: $claimed" }
// Kotlin's `"/locks/leader".doesNotExist` extension property becomes a get-prefixed
// static here. Both read the key's version: a key that was never created — or has
// since been deleted — has version 0.
boolean claimed = transaction(client, txn -> {
txn.If(getDoesNotExist("/locks/leader"));
txn.Then(setTo("/locks/leader", "node-1"));
return txn;
}).isSucceeded();
// This is create-if-absent as ONE atomic step. A get-then-put would race: two clients
// could both read "absent" and both write.
System.out.println("Claimed leadership: " + claimed);
String.doesExist and String.doesNotExist are Cmp-valued extension properties, sugar
over version(0):
val String.doesNotExist: Cmp get() = equalTo(this, CmpTarget.version(0))
val String.doesExist: Cmp get() = greaterThan(this, CmpTarget.version(0))
Version 0 means "never created, or created and since deleted" — etcd resets the version on delete, so a recreated key starts over at 1.
This tiny transaction is the atomic primitive that the presence checks on the
key/value page explicitly cannot give you. isKeyNotPresent
followed by putValue is two round trips with a window between them, and two clients can both
win. The transaction has no window: exactly one of them gets isSucceeded = true, and it is
the server that decides. Leader election, lock acquisition, and lazy initialisation are all
this shape.
Deleting inside a transaction¶
// Then and Else take Ops, so a transaction can delete as well as put, and every op in
// the branch lands atomically with the others.
transaction(client, txn -> {
txn.If(getDoesExist("/queue/head"));
txn.Then(deleteOp("/queue/head"), setTo("/queue/consumed", 1));
txn.Else(setTo("/audit/last", "queue was empty"));
return txn;
});
Then and Else take Ops, not just puts:
| Helper | Builds |
|---|---|
key setTo value |
Op.PutOp — infix, for String, Int, Long, ByteSequence |
key.setTo(value, putOption) |
Op.PutOp with options — a lease id, say |
deleteOp(key) |
Op.DeleteOp |
deleteOp(key, deleteOption) |
Op.DeleteOp over a range, with prevKV |
Ops in a branch land together, atomically, which is the answer to the caveat on
deleteKeys: several deleteOps in one Then cannot half-apply.
setTo(value, putOption) is how a recipe writes a key that is bound to a lease and guarded
by a comparison in the same atomic step — claim the key only if nobody holds it, and tie it to
your lease so it cannot outlive you. See Leases.
Compare-and-swap¶
This is what the whole page has been building to. modRevision records the revision of the
last write to a key, so comparing against it answers exactly one question: has anybody touched
this key since I read it?
// The CAS loop under DistributedAtomicLong: read the value along with its modRevision,
// then commit only if nobody has touched the key since that read.
var committed = false
while (!committed) {
val kv = client.getResponse("/counters/hits").kvs.first()
val next = kv.value.asLong + 1
val response =
client.transaction {
If(equalTo("/counters/hits", CmpTarget.modRevision(kv.modRevision)))
Then("/counters/hits" setTo next)
}
// A false isSucceeded means a concurrent writer won the race, so the read is stale
// and the loop retries. Transactions are deliberately never retried for you: a
// failed commit is ambiguous (it may have applied), so the decision stays yours.
committed = response.isSucceeded
}
// The CAS loop under DistributedAtomicLong: read the value along with its modRevision,
// then commit only if nobody has touched the key since that read.
boolean committed = false;
while (!committed) {
List<KeyValue> kvs = getResponse(client, "/counters/hits").getKvs();
KeyValue kv = kvs.get(0);
long next = getAsLong(kv.getValue()) + 1;
TxnResponse response = transaction(client, txn -> {
txn.If(equalTo("/counters/hits", CmpTarget.modRevision(kv.getModRevision())));
txn.Then(setTo("/counters/hits", next));
return txn;
});
// A false isSucceeded means a concurrent writer won the race, so the read is stale
// and the loop retries. Transactions are deliberately never retried for you: a
// failed commit is ambiguous (it may have applied), so the decision stays yours.
committed = response.isSucceeded();
}
Read the value with its modRevision, compute the new value locally, then commit only if the
modRevision is unchanged. If another client wrote in between, the comparison fails, nothing
is applied, and you re-read and retry against the new value. No lock, no lease, no coordinator
— and no lost update.
DistributedAtomicLong is this loop with backoff around it. Read
Counters before writing your own.
Transactions are never retried for you
Every other extension in common/ retries on retriable statuses. transaction { } does
not — it gets the operation timeout and nothing else, deliberately.
A commit that fails in an ambiguous way may already have applied. Re-sending it could double-apply a non-idempotent change; a CAS that silently retries could increment twice. Only the caller knows whether their transaction is safe to re-send, so the retry decision stays with the caller. That is why the loop above is written out rather than hidden: the retry is a correctness decision, not transport plumbing.
CAS or lock?
A CAS loop beats a lock whenever the work between read and write is fast and local. It needs no lease, cannot be lost by expiry, and costs one round trip when uncontended. Reach for a lock when the critical section is slow, touches many keys, or involves something outside etcd — under heavy contention a CAS loop can livelock while a lock queues fairly.