Distributed Coordination for Working Developers
2025
To Gaby and Dani, for every late night and every early morning you gave me so I could design, architect, build, and test this system. This book exists because you believed in it before anyone else did.
Kahuna started as a question: what would it take to build a coordination system that a working developer could pick up in an afternoon and run in production the next week? Not a research prototype. Not a system that requires a PhD to operate. A system with locks, key-value storage, transactions, and sequences, all backed by consensus, all accessible through a simple API.
That question turned into code, and the code turned into something that needed a book.
This book is for developers and operators who build and run distributed systems. You do not need prior experience with consensus protocols, MVCC, or actor models. The book starts with practical usage (how to store a value, acquire a lock, run a transaction) and progresses to internal mechanisms (how Raft replicates entries, how the two-phase commit protocol works, how the WAL is structured).
If you are evaluating Kahuna for a project, Part I (Chapters 1 through 7) gives you everything you need to make that decision. If you are already running Kahuna and need to understand its behavior under failure, Part III (Chapters 14 through 23) explains the internals. If you are operating a production cluster, Part IV (Chapters 24 through 29) covers deployment, scaling, backup, observability, and testing.
The book is divided into four parts:
Part I: Using Kahuna (Chapters 1 through 13). These chapters teach you how to use Kahuna’s three primitives (key-value store, distributed locks, distributed sequences), how to write transactions and scripts, and how to apply these tools to common patterns such as leader election, idempotent operations, and transactional workflows.
Part II: Architecture (Chapters 14 and 15). These two chapters provide the map. Chapter 14 introduces the component hierarchy. Chapter 15 traces a single request through every layer from client to disk and back. Every subsequent chapter references these two.
Part III: Mechanisms (Chapters 16 through 23). Each chapter explains one internal subsystem: partitioning, consensus, the actor model, MVCC, transactions, locks and sequences, persistence, and recovery. These chapters are independent of each other. Read them in any order after Part II.
Part IV: Operations (Chapters 24 through 29). These chapters cover deployment, cluster membership, range management, backup and point-in-time recovery, observability and tuning, and testing. They combine the external knowledge from Part I with the internal knowledge from Part III.
The appendices provide reference material: the script language syntax, the configuration reference, the CLI command reference, error codes, a comparison with other systems, and a glossary.
Code examples use C# for client code and Kahuna Script for
server-side scripts. Command-line examples show the
kahuna-cli interactive shell or the
kahuna-server flags. Configuration flags appear with their
default values in tables throughout the text.
Each internal chapter ends with a “Failure Scenarios” section that describes what goes wrong when specific conditions occur. These sections are based on real behavior observed through the Jepsen test suite.
Building a distributed system is not a solo effort, even when it feels like one at 3 AM. The Jepsen test suite found real bugs that made Kahuna better. The Raft paper by Diego Ongaro and John Ousterhout provided the foundation. The open-source community around .NET provided the runtime and tooling that made the implementation possible.
Thank you to everyone who filed an issue, asked a question, or tried Kahuna on a workload it was not designed for. Those conversations shaped the system more than any design document.
And thank you for picking up this book. I hope it helps you build something reliable.
Andres Gutierrez
Imagine you are running three copies of a payment service behind a load balancer. A customer submits an order. The request lands on node A, which checks the customer’s balance, deducts the amount, and records the charge. But what happens if the same request, retried after a timeout, lands on node B? Node B checks the balance, sees the old value (the deduction hasn’t propagated yet), and charges the customer a second time.
This is not a hypothetical scenario. It is one of the most common bugs in distributed systems: two processes operating on shared state without coordination. The fix is not more careful coding. The fix is a coordination primitive (a lock, a conditional write, or an atomic transaction) that both processes agree to respect.
Building those coordination primitives yourself is hard. Getting them right under network partitions, process crashes, and clock drift is harder still. This is the problem that Kahuna solves.
When an application runs on a single machine, coordination is straightforward. A thread acquires a mutex, does its work, and releases the mutex. The operating system guarantees that only one thread holds the mutex at a time. The application doesn’t need to think about what happens if the CPU loses power mid-operation, because the OS handles that, too.
Distributed systems don’t have a single machine. They have multiple processes running on multiple nodes, connected by a network that can delay, reorder, or drop messages. There is no shared memory. There is no global clock. There is no single entity that can decide “this process goes first.”
And yet, distributed applications need all of the things that a single-machine application takes for granted:
Each of these requirements is a coordination problem. Solving any one of them requires agreement among multiple nodes. Solving them correctly, under failures, requires a consensus protocol: a formal mechanism by which a group of nodes agrees on a single value or a single order of operations, even when some nodes are unreachable.
Consensus protocols are well-studied. Raft, Paxos, and Zab have been implemented in production systems for over a decade. But implementing a consensus protocol is not the same as having a coordination system. A coordination system wraps the protocol in a usable API: locks you can acquire, keys you can read and write, transactions you can commit, and sequences you can allocate from.
That is what Kahuna provides.
Kahuna is an open-source distributed coordination system built in C# on .NET. It provides three core primitives:
A distributed key-value store. Store and retrieve data across a cluster of nodes, with strong consistency, multi-version concurrency control (MVCC), and support for transactions.
Distributed locks. Acquire time-bounded locks (leases) on named resources, with monotonically increasing fencing tokens that prevent stale lock holders from corrupting data.
A distributed sequencer. Generate globally unique, monotonically increasing numbers without a single-node bottleneck.
These three primitives share a single cluster, a single consensus layer (Raft), and a single operational surface. You deploy one system instead of three.
Kahuna organizes data into partitions. Each partition is an independent unit with its own Raft group: its own leader, its own replicas, and its own log. This design allows Kahuna to scale horizontally: more partitions means more throughput, because different partitions can be led by different nodes.
Every write in Kahuna goes through Raft consensus. This means that once a write is acknowledged, it is durable on a majority of replicas. If a node fails, another node can take over as leader for that partition without losing committed data.
Kahuna is a Hawaiian word that refers to an expert in any field. Historically, it has been used to refer to doctors, surgeons, priests, and sorcerers.
At its core, Kahuna is a distributed key-value store. You write a value under a string key, and you can read it back from any node in the cluster. Kahuna routes the key to the correct partition, forwards the request to the partition’s leader, and replicates the write through Raft before acknowledging it.
But Kahuna’s key-value store goes well beyond simple get and set. It provides:
Revisions. Every write to a key increments a revision number. You can read the current revision, and you can use it for compare-and-swap operations: “set this key to X, but only if its current revision is 7.” If another writer changed the key in the meantime, the operation fails instead of silently overwriting.
Conditional writes. Beyond compare-and-swap on revisions, you can set a key only if it doesn’t exist yet, only if it already exists, or only if its current value matches a specific value.
Time-to-live (TTL). Keys can expire after a specified duration. This is useful for session tokens, ephemeral state, and presence detection.
Durability levels. Keys can be persistent (survive restarts, replicated via Raft) or ephemeral (fast, in-memory only, lost on restart). You choose per key.
Range queries. You can scan keys by prefix or by range, with pagination support for large result sets.
Transactions. You can read and write multiple keys atomically, with either optimistic or pessimistic concurrency control.
Here is what a simple key-value operation looks like in C#:
var client = new KahunaClient("http://localhost:2070");
// Set a key
await client.SetKeyValue("user:1001:name", "Alice");
// Get a key
KahunaKeyValue result = await client.GetKeyValue("user:1001:name");
Console.WriteLine(result.ValueAsString()); // "Alice"
Console.WriteLine(result.Revision); // 1The key user:1001:name is routed to a partition based on
its prefix. The write goes through Raft consensus. The read goes to the
partition’s leader, which confirms it is still the leader before
responding.
A distributed lock is a lease: a time-bounded claim on a named resource. While a process holds the lock, no other process can acquire it. When the lease expires (because the holder crashed, or because it simply didn’t renew in time), the lock becomes available to other processes.
Leases alone are not enough, though. Consider this scenario:
Process A just wrote to a shared resource without holding the lock. This is the stale lock holder problem, and it can corrupt data even when the lock implementation itself is correct.
Kahuna prevents this with fencing tokens. Every time a lock is acquired, Kahuna assigns it a monotonically increasing integer. Process A might get fencing token 5. When the lease expires and process B acquires the lock, process B gets fencing token 6. If the downstream system checks the fencing token before accepting a write, it can reject process A’s stale write (token 5) because it has already seen a higher token (6).
// Acquire a lock with a 10-second lease
await using KahunaLock lockHandle =
await client.GetOrCreateLock(
"resource:payment:1001",
expiresMs: 10000);
if (lockHandle.IsAcquired)
{
Console.WriteLine($"Lock acquired, fencing token: {lockHandle.FencingToken}");
// Do the protected work
}
// Lock is released automatically when lockHandle is disposedThe await using pattern ensures the lock is released
even if the code throws an exception. If the process crashes instead of
throwing, the lease expires and the lock becomes available.
Many applications need unique identifiers: order numbers, event IDs, invoice numbers, log sequence numbers. These identifiers must be:
Database auto-increment columns satisfy the first two requirements, but not the third. UUIDs satisfy the first and third, but not the second (they are not sortable by generation time in their standard form). Snowflake IDs satisfy all three, but require careful clock management and machine ID assignment.
Kahuna’s sequencer takes a different approach. It uses block-based allocation: a node reserves a block of sequence values (for example, 1000 at a time) with a single Raft commit. It then serves individual values from that block without any further consensus operations. When the block is exhausted, it reserves another one.
This design means that one Raft commit can produce hundreds or thousands of unique values. The trade-off is gaps: if a node crashes with 300 values remaining in its block, those 300 values are never used. The sequence jumps from wherever it was to the next block boundary. Values are guaranteed to be unique, but they are not guaranteed to be gap-free.
// Create a sequence
await client.CreateSequence("order-ids");
// Allocate the next value
KahunaSequence seq = await client.NextSequenceValue("order-ids");
Console.WriteLine($"Order ID: {seq.CurrentValue}"); // 1
// Allocate a batch of values
KahunaSequenceRange range = await client.ReserveSequenceRange("order-ids", 100);
Console.WriteLine($"Range: {range.Start} to {range.End}"); // e.g. 2 to 101For most applications, gaps are acceptable. If you need gap-free sequences (for example, invoice numbers in jurisdictions that require them), you can set the block size to 1, which forces a Raft commit per value. This eliminates gaps but reduces throughput.
Kahuna is designed for coordination workloads: operations where multiple processes need to agree on shared state. Here are the scenarios where it fits well:
Distributed locking. Protecting shared resources across services: job processing queues, rate limiters, payment flows, leader election, and any workflow where “only one at a time” is a correctness requirement.
Coordination metadata. Storing configuration that multiple services read and update: feature flags, service registrations, routing tables, cluster membership records.
Transactional updates to small key sets. Atomically updating a handful of related keys: account balances, inventory counts, state machines. Kahuna’s transactions are designed for small working sets (tens of keys), not bulk data operations.
Unique ID generation. Producing globally unique, monotonically increasing identifiers without a centralized bottleneck.
Session and presence management. Tracking which services or users are currently active, using ephemeral keys with TTL.
Kahuna is not a general-purpose database, and using it as one will lead to poor results.
Bulk data storage. If you need to store millions of rows with complex queries, use a database (PostgreSQL, CockroachDB, CamusDB). Kahuna stores coordination metadata and small working sets, not application data.
Message queuing. If you need publish-subscribe, message routing, or stream processing, use a message broker (Kafka, RabbitMQ, NATS). Kahuna can coordinate producers and consumers, but it is not a message transport.
High-throughput caching. For large-scale cache workloads with millions of reads per second, use Redis or Memcached. Kahuna can serve as a cache for low-to-medium workloads (it supports TTL, ephemeral durability, and no-revision writes), but its strong consistency and Raft consensus path add overhead that dedicated cache systems avoid.
Analytics and reporting. If you need aggregations, joins, or full-text search, use a database or search engine. Kahuna’s query model is key-based, not relational.
The simplest rule: if the data is coordination state (who holds the lock, what is the current sequence number, what is the latest configuration version), Kahuna is a good fit. If the data is application state (user profiles, product catalogs, order histories), use a database.
Several established systems provide overlapping capabilities. Understanding the differences helps you choose the right tool.
etcd is a distributed key-value store built on Raft. It is the coordination backbone of Kubernetes. Like Kahuna, it provides linearizable reads and writes, leases, and watches.
Differences: etcd does not provide a built-in sequencer, a
transaction scripting language, or MVCC-based snapshot isolation for
multi-key transactions. etcd’s lock API (in the concurrency
package) does not return a fencing token directly. A developer can read
the key’s CreateRevision and use it as a fencing value, but
the application must implement that pattern manually. Kahuna returns a
fencing token as an explicit field on every lock acquisition. etcd uses
a single Raft group, so every node holds every key. This limits etcd to
small clusters (typically 3 or 5 nodes) and a total data size of a few
gigabytes. Kahuna uses multiple Raft groups (one per partition) and a
configurable replication factor. Each partition is replicated to a
subset of nodes, not all of them. This means a Kahuna cluster can grow
well beyond 5 or 9 nodes: keys and locks are distributed across many
partitions, and each partition only consumes resources on its replicas.
etcd is written in Go; Kahuna is written in C#.
ZooKeeper is the original distributed coordination service. It provides a hierarchical namespace (similar to a filesystem), ephemeral nodes, watches, and sequential nodes for ordering.
Differences: ZooKeeper’s data model is tree-structured (znodes),
while Kahuna’s is flat (key-value). ZooKeeper does not provide multi-key
transactions, a scripting language, or a built-in sequencer with block
allocation. ZooKeeper’s lock recipe uses sequential ephemeral znodes.
The sequence number on the znode (or the znode’s czxid) is
monotonically increasing and can serve as a fencing token, but the
application must read and propagate it manually. Like etcd, ZooKeeper
uses a single consensus group (Zab), so every node holds every znode.
This limits cluster size and total data capacity. Kahuna distributes
data across partitions with per-partition replication, which allows it
to scale to larger clusters. ZooKeeper is written in Java; Kahuna is
written in C#.
Consul provides service discovery, health checking, and a key-value store. Its KV store supports CAS operations and distributed locks via sessions.
Differences: Consul is primarily a service mesh and discovery tool;
its KV store is a secondary feature. Consul does not provide multi-key
transactions, MVCC, snapshot isolation, or a sequencer. Consul’s KV
entries carry a ModifyIndex that increases monotonically,
so a lock holder can use it as a fencing value, but the application must
implement that pattern. Consul is written in Go; Kahuna is written in
C#.
Redis is an in-memory data structure store. With Redlock (a distributed lock algorithm), it provides distributed locking. Redis Cluster provides horizontal scaling with hash-slot partitioning.
Differences: Redis prioritizes throughput and low latency over strong consistency. Redlock has been the subject of academic debate regarding its safety guarantees under certain failure modes. Kahuna’s locks are built on Raft consensus with linearizable operations and fencing tokens. Redis does not provide multi-key transactions with snapshot isolation across partitions. Redis is written in C; Kahuna is written in C#.
| Feature | Kahuna | etcd | ZooKeeper | Consul | Redis |
|---|---|---|---|---|---|
| Distributed KV store | Yes | Yes | Yes (tree) | Yes | Yes |
| Strong consistency | Yes (Raft) | Yes (Raft) | Yes (Zab) | Yes (Raft) | No (async replication) |
| Distributed locks | Yes | Yes (leases) | Yes (ephemeral nodes) | Yes (sessions) | Yes (Redlock) |
| Fencing tokens | Yes (built-in) | Manual (via revision) | Manual (via czxid) | Manual (via ModifyIndex) | No |
| Multi-key transactions | Yes (2PC + MVCC) | Yes (mini-transactions) | No | No | No |
| Snapshot isolation | Yes | No | No | No | No |
| Distributed sequencer | Yes | No | Yes (sequential nodes) | No | No |
| Transaction scripting | Yes | No | No | No | Yes (Lua) |
| Partitioned (multi-Raft-group) | Yes | No (single group) | No (single group) | No (single group) | Yes (hash slots) |
| Per-partition replication factor | Yes | No (all nodes) | No (all nodes) | No (all nodes) | Yes (per shard) |
| Horizontal data scaling | Yes | Limited | Limited | Limited | Yes |
| Primary language | C# | Go | Java | Go | C |
This comparison is factual, not promotional. Each system makes different trade-offs, and the right choice depends on your workload, your team’s expertise, and your operational requirements.
Before diving into the details in later chapters, here is a brief sketch of how Kahuna is organized internally. You don’t need to understand all of this now. Part III of the book covers each component in depth.
A Kahuna cluster consists of multiple nodes. Each node runs the same server binary and can serve any request by routing it to the appropriate partition leader.
Data is organized into partitions. Each partition is a range of keys managed by its own independent Raft group. A Raft group has a leader (which handles writes and linearizable reads) and followers (which replicate the leader’s log). If the leader fails, the remaining replicas elect a new leader.
When a client sends a request, the receiving node determines which partition owns the key (based on hashing or range mapping), finds the leader for that partition, and forwards the request. If the receiving node is the leader, it handles the request locally. The leader proposes the write to Raft, waits for a majority of replicas to acknowledge, and then responds to the client.
Client
→ Node (any node can receive)
→ Partition resolution (which partition owns this key?)
→ Leader (forward to leader if this node isn't it)
→ Raft consensus (replicate to majority)
→ Acknowledge to client
Kahuna uses Hybrid Logical Clocks (HLC) for ordering events across nodes. HLC combines a physical timestamp with a logical counter to produce globally unique, causally ordered timestamps without requiring synchronized clocks. Every committed write gets an HLC timestamp that establishes its position in the total order of operations.
The key-value store uses Multi-Version Concurrency Control (MVCC): each key retains multiple versions, indexed by their HLC timestamp. This allows transactions to read a consistent snapshot of the data at a specific point in time, even while other transactions are writing to the same keys.
To close this chapter, here is a complete C# program that connects to a Kahuna cluster, uses all three primitives, and handles basic errors. Don’t worry about understanding every detail. The following chapters will explain each operation thoroughly.
using Kahuna.Client;
// Connect to a Kahuna cluster
var client = new KahunaClient("http://localhost:2070");
// --- Key-Value ---
// Store a value
await client.SetKeyValue("config:feature:dark-mode", "enabled");
// Read it back
var kv = await client.GetKeyValue("config:feature:dark-mode");
Console.WriteLine($"Key: {kv.Key}, Value: {kv.ValueAsString()}, Revision: {kv.Revision}");
// Conditional write: update only if the revision hasn't changed
var updated = await client.TryCompareRevisionAndSetKeyValue(
"config:feature:dark-mode",
"disabled",
kv.Revision);
Console.WriteLine($"Update succeeded: {updated.Success}");
// --- Distributed Lock ---
// Acquire a lock with a 5-second lease
await using var lockHandle = await client.GetOrCreateLock(
"job:nightly-report",
expiresMs: 5000);
if (lockHandle.IsAcquired)
{
Console.WriteLine($"Acquired lock with fencing token: {lockHandle.FencingToken}");
// Protected work goes here
}
// --- Sequencer ---
// Create a sequence and allocate values
await client.CreateSequence("invoice-numbers");
var next = await client.NextSequenceValue("invoice-numbers");
Console.WriteLine($"Next invoice number: {next.CurrentValue}");This program doesn’t handle retries, leader changes, or transaction conflicts yet. Chapter 13 covers all of those. For now, the point is to see the shape of the API: connect, operate, and let the cluster handle replication and consistency.
The rest of this book is organized into four parts:
Part I, Using Kahuna (Chapters 2 to 7), walks through each primitive in detail. You’ll start a cluster, learn the full key-value API, master distributed locks and fencing tokens, use the sequencer, write transactions, and learn the Kahuna scripting language.
Part II, Building Distributed Applications (Chapters 8 to 13), applies these primitives to real engineering problems: leader election, idempotent workflows, service coordination, transactional pipelines, and failure handling.
Part III, Understanding Kahuna Internals (Chapters 14 to 23), opens the hood. You’ll trace a request from the client through routing, consensus, the actor model, MVCC, the two-phase commit protocol, persistence, and recovery. Every chapter connects to actual source code.
Part IV, Operating and Extending Kahuna (Chapters 24 to 29), covers production operations: deployment, cluster membership, range management, backup and point-in-time recovery, observability, performance tuning, and Jepsen testing.
You don’t need to read the book linearly. Parts I and II are designed for developers who want to use Kahuna. Parts III and IV are for those who want to understand or operate it. But the concepts build on each other, and the book is designed to be read in order if you have the time.
Let’s begin. The next chapter gets a cluster running on your machine.
This chapter takes you from zero to a working Kahuna environment. By the end, you will have a running cluster, a command-line client, and a C# application that reads, writes, locks, and generates sequence numbers. You will also crash a node and see the cluster recover on its own.
Kahuna runs on .NET. You need:
Verify your .NET installation:
dotnet --versionIf this prints a version number of 10.0 or higher, you are ready.
Kahuna is distributed as a .NET global tool. Install it with a single command:
dotnet tool install -g Kahuna.ServerThis places the kahuna-server command on your PATH. Run
it:
kahuna-serverWith no arguments, the server starts a standalone node. It listens for HTTP connections on port 2070. The output shows the storage paths and the ports the node is using.
By default, the node stores its key-value data and Raft write-ahead log under your user data directory:
~/.local/share/kahuna%LOCALAPPDATA%\kahunaBoth paths are printed at startup. To store data in a different
location, set the KAHUNA_HOME environment variable, or pass
--storage-path and --wal-path explicitly.
The standalone node elects itself as the leader for every partition. It does not need any peer configuration. This makes it the fastest way to start experimenting.
Kahuna supports three storage backends:
| Backend | Flag | Persistence | Use case |
|---|---|---|---|
| RocksDB | --storage rocksdb |
Durable | Production (default) |
| SQLite | --storage sqlite |
Durable | Alternative persistent backend |
| Memory | --storage memory |
Ephemeral | Testing and prototyping |
The default is RocksDB. For quick experiments where you do not need data to survive a restart, use the memory backend:
kahuna-server --storage memory --wal-storage memoryThe standalone node serves HTTP only. To enable HTTPS, supply a certificate:
kahuna-server \
--https-ports 2071 \
--https-certificate /path/to/certificate.pfxFor local development, a self-signed certificate is sufficient. The client SDK has an option to skip certificate validation (covered later in this chapter).
The CLI is a separate .NET global tool:
dotnet tool install -g Kahuna.ControlThis installs the kahuna-cli command. By default, it
connects to http://localhost:2070, which matches the
standalone server’s default port.
Start a standalone server in one terminal window. Open a second terminal and try the following commands.
Write a value:
kahuna-cli --set "users/alice" --value '{"name":"Alice","role":"admin"}'Read it back:
kahuna-cli --get "users/alice"The output shows the value and metadata, including the revision number. Every write increments the revision.
Keys can have a time-to-live (TTL). The --expires flag
takes a value in milliseconds:
kahuna-cli --set "session/abc123" --value "active" --expires 30000This key expires after 30 seconds. After that, a --get
on this key returns nothing.
Acquire a lock named jobs/send-email. The
--expires flag specifies the lease duration in
milliseconds:
kahuna-cli --lock "jobs/send-email" --owner "worker-1" --expires 10000The output confirms the lock is acquired. It also shows the fencing token, a monotonically increasing number. Each time the lock changes hands, the fencing token increases.
Release the lock:
kahuna-cli --unlock "jobs/send-email" --owner "worker-1"Only the owner that acquired the lock can release it.
Create a sequence named order-ids:
kahuna-cli --create-sequence "order-ids"Retrieve the next value:
kahuna-cli --next-sequence "order-ids"Each call returns a new, unique, monotonically increasing number. You can also reserve a range of values at once:
kahuna-cli --reserve-sequence "order-ids" --count 10This reserves 10 values and returns the start and end of the range.
By default, the CLI prints results in a human-readable console format. For scripting, use JSON output:
kahuna-cli --get "users/alice" --format jsonA single node is fine for development. For testing replication and fault tolerance, you need multiple nodes. Kahuna provides two ways to run a local cluster: Docker Compose and shell scripts.
The Kahuna repository includes a Docker Compose file at
docker/local.yml. This file defines a three-node cluster on
a Docker bridge network.
Clone the repository and start the cluster:
git clone https://github.com/kahunakv/kahuna.git
cd kahuna
docker compose -f docker/local.yml up -dThe three nodes expose these ports on your host:
| Node | HTTP Port | HTTPS/Raft Port |
|---|---|---|
| kahuna1 | 8081 | 8082 |
| kahuna2 | 8083 | 8084 |
| kahuna3 | 8085 | 8086 |
Each node has its own storage volume. Each node’s
--initial-cluster flag lists the other two nodes’ Raft
endpoints. This is how nodes discover each other at startup.
Verify the cluster is running:
docker compose -f docker/local.yml psAll three containers should show a healthy status.
Connect the CLI to any node in the cluster. For the Docker cluster,
the HTTPS ports use a self-signed certificate, so add
--insecure to skip validation:
kahuna-cli -c "https://localhost:8082" --insecure --set "test/key" --value "hello"
kahuna-cli -c "https://localhost:8084" --insecure --get "test/key"Notice that you write to node 1 and read from node 2. Kahuna routes the request to the correct partition leader internally, and the value is replicated through Raft. The read returns the same value regardless of which node you connect to.
If you prefer to run the cluster outside Docker, the repository includes a script that starts three nodes as local processes:
cd kahuna
./scripts/run-cluster.shThis script builds the server from source, then starts three nodes on localhost. The port layout is the same as the Docker cluster:
| Node | HTTP | HTTPS/Raft |
|---|---|---|
| kahuna1 | 8081 | 8082 |
| kahuna2 | 8083 | 8084 |
| kahuna3 | 8085 | 8086 |
Press Ctrl+C to stop all three nodes.
For a standalone node from source, use:
./scripts/run-standalone.shBoth scripts accept environment variables for customization:
| Variable | Default | Description |
|---|---|---|
KAHUNA_STORAGE |
rocksdb |
Storage backend (rocksdb or memory) |
KAHUNA_PARTITIONS |
3 |
Initial partition count |
KAHUNA_DATA_DIR |
/tmp/kahuna-cluster |
Data directory (rocksdb only) |
For example, to run a fully ephemeral cluster:
KAHUNA_STORAGE=memory ./scripts/run-cluster.shWhen a node starts, it needs to know about its peers. Kahuna uses
static discovery: each node’s --initial-cluster flag lists
the Raft endpoints of the other nodes. The node contacts those peers and
forms a Raft group for each partition.
The key configuration flags for clustering are:
| Flag | Purpose |
|---|---|
--raft-nodename |
Unique name for this node |
--raft-nodeid |
Unique integer ID for this node |
--raft-host |
Host address for Raft communication |
--raft-port |
Port for Raft communication |
--initial-cluster |
Raft endpoints of the other nodes |
--initial-cluster-partitions |
Number of partitions (default: 3) |
For example, to manually start node 1 of a three-node cluster:
kahuna-server \
--raft-nodename kahuna1 \
--raft-nodeid 1 \
--raft-host 127.0.0.1 \
--raft-port 8082 \
--http-ports 8081 \
--https-ports 8082 \
--https-certificate certificate.pfx \
--initial-cluster 127.0.0.1:8084 127.0.0.1:8086 \
--initial-cluster-partitions 3Nodes 2 and 3 use the same pattern, with their own node name, ID,
ports, and peer list. Each node lists only the other nodes in
--initial-cluster, not itself.
The Kahuna .NET client library communicates with the server over gRPC. Install it from NuGet:
dotnet add package Kahuna.ClientFor a single endpoint:
using Kahuna.Client;
var client = new KahunaClient("http://localhost:2070");For a cluster with multiple endpoints, pass an array of URLs:
var client = new KahunaClient(new[]
{
"https://localhost:8082",
"https://localhost:8084",
"https://localhost:8086"
});The client distributes requests across the endpoints using round-robin. Kahuna routes each request to the correct partition leader internally, so any endpoint can handle any request.
When connecting to a cluster that uses self-signed certificates (such as the local Docker cluster), configure the client to skip TLS validation:
var client = new KahunaClient(
new[] { "https://localhost:8082", "https://localhost:8084", "https://localhost:8086" },
options: new KahunaOptions { AllowInsecureCertificateValidation = true }
);Do not use this setting in production.
Write and read a value:
KahunaKeyValue setResult = await client.SetKeyValue("users/alice", "admin");
Console.WriteLine($"Revision: {setResult.Revision}");
KahunaKeyValue getResult = await client.GetKeyValue("users/alice");
Console.WriteLine($"Value: {getResult.ValueAsString()}");
Console.WriteLine($"Revision: {getResult.Revision}");Every successful write returns a KahunaKeyValue object.
The Success property tells you whether the write went
through. The Revision property is a monotonically
increasing number that changes with every update to that key.
Write with a TTL (in milliseconds):
await client.SetKeyValue("session/token-xyz", "active", expiryTime: 60000);This key expires after 60 seconds.
Acquire a lock, do some work, and release it:
await using KahunaLock lockHandle = await client.GetOrCreateLock(
resource: "jobs/send-email",
expiry: TimeSpan.FromSeconds(10),
durability: LockDurability.Persistent
);
if (lockHandle.IsAcquired)
{
Console.WriteLine($"Lock acquired. Fencing token: {lockHandle.FencingToken}");
// Do the protected work here.
}
else
{
Console.WriteLine("Could not acquire the lock. Another process holds it.");
}
// The lock is released automatically when the using block ends.The GetOrCreateLock method returns a
KahunaLock object. If the lock is already held by another
owner, IsAcquired is false. The lock implements
IAsyncDisposable, so it is released automatically at the
end of the await using block.
The FencingToken is a monotonically increasing number.
Each time a lock changes hands, the fencing token increases. You can
pass this token to downstream services to ensure that a stale lock
holder cannot overwrite newer data. Chapter 4 covers fencing in
detail.
Create a sequence and generate values:
KahunaSequence seq = await client.CreateSequence("order-ids");
Console.WriteLine($"Sequence created. Current value: {seq.CurrentValue}");
long nextId = await client.NextSequenceValue("order-ids");
Console.WriteLine($"Next value: {nextId}");
KahunaSequenceRange range = await client.ReserveSequenceRange("order-ids", count: 10);
Console.WriteLine($"Reserved range: {range.Start} to {range.End}");NextSequenceValue returns a single value.
ReserveSequenceRange reserves a batch of values and returns
the start and end of the range. The values in a reserved range are
guaranteed to be unique and monotonically increasing across all
clients.
The following console application connects to a standalone Kahuna node and exercises all three primitives:
using Kahuna.Client;
using Kahuna.Shared.Locks;
var client = new KahunaClient("http://localhost:2070");
// --- Key-Value ---
KahunaKeyValue kv = await client.SetKeyValue("demo/greeting", "Hello, Kahuna!");
Console.WriteLine($"Set key 'demo/greeting' at revision {kv.Revision}");
KahunaKeyValue read = await client.GetKeyValue("demo/greeting");
Console.WriteLine($"Read: {read.ValueAsString()} (revision {read.Revision})");
// Update the key.
KahunaKeyValue updated = await client.SetKeyValue("demo/greeting", "Hello again!");
Console.WriteLine($"Updated to revision {updated.Revision}");
// --- Distributed Lock ---
await using KahunaLock lockHandle = await client.GetOrCreateLock(
resource: "demo/my-lock",
expiry: TimeSpan.FromSeconds(30),
durability: LockDurability.Persistent
);
if (lockHandle.IsAcquired)
{
Console.WriteLine($"Lock acquired. Fencing token: {lockHandle.FencingToken}");
}
// --- Sequence ---
await client.CreateSequence("demo/counter");
for (int i = 0; i < 5; i++)
{
long id = await client.NextSequenceValue("demo/counter");
Console.WriteLine($"Sequence value: {id}");
}
Console.WriteLine("Done.");To run this example, create a new console project:
mkdir kahuna-demo && cd kahuna-demo
dotnet new console
dotnet add package Kahuna.ClientReplace the contents of Program.cs with the code above.
Start a standalone Kahuna server in another terminal
(kahuna-server), then run the application:
dotnet runYou should see output similar to:
Set key 'demo/greeting' at revision 1
Read: Hello, Kahuna! (revision 1)
Updated to revision 2
Lock acquired. Fencing token: 1
Sequence value: 0
Sequence value: 1
Sequence value: 2
Sequence value: 3
Sequence value: 4
Done.
Distributed systems earn their value when something goes wrong. This experiment demonstrates that Kahuna keeps serving requests after a node failure.
Start the three-node Docker cluster:
docker compose -f docker/local.yml up -dWrite a key through node 1:
kahuna-cli -c "https://localhost:8082" --insecure \
--set "experiment/counter" --value "42"Verify the key is readable from all three nodes:
kahuna-cli -c "https://localhost:8082" --insecure --get "experiment/counter"
kahuna-cli -c "https://localhost:8084" --insecure --get "experiment/counter"
kahuna-cli -c "https://localhost:8086" --insecure --get "experiment/counter"All three should return 42.
Stop the second container:
docker stop kahuna2Now try reading and writing through the remaining nodes:
kahuna-cli -c "https://localhost:8082" --insecure --get "experiment/counter"
kahuna-cli -c "https://localhost:8086" --insecure \
--set "experiment/counter" --value "43"
kahuna-cli -c "https://localhost:8082" --insecure --get "experiment/counter"The reads and writes still work. Two out of three nodes form a majority (a quorum), so Raft can still reach consensus on new writes.
docker start kahuna2After a few seconds, node 2 catches up with the other two. Read the key through node 2:
kahuna-cli -c "https://localhost:8084" --insecure --get "experiment/counter"It returns 43, the value that was written while it was
down. Raft replicated the missed writes when node 2 rejoined the
cluster.
Stop two nodes:
docker stop kahuna2 kahuna3Now try to write:
kahuna-cli -c "https://localhost:8082" --insecure \
--set "experiment/counter" --value "44"This request fails or times out. With only one node remaining out of three, Raft cannot form a quorum. The cluster refuses to accept writes because it cannot guarantee that the write will be durable on a majority of replicas.
This is intentional. Kahuna chooses consistency over availability: it will not accept a write that it cannot safely replicate.
Bring the nodes back:
docker start kahuna2 kahuna3After a few seconds, the cluster resumes normal operation.
In this chapter you installed Kahuna’s server and CLI tools, ran a standalone node, operated a three-node cluster, and connected a C# application. You used all three primitives (key-value storage, distributed locks, and sequences) from both the CLI and the client SDK. You also ran a failure experiment that demonstrated Raft’s ability to maintain availability with a majority of nodes and to replicate missed writes when a failed node returns.
The next chapter explores key-value operations in depth: conditional writes, revisions, TTL, range queries, and durability levels.
Chapter 2 showed how to set and get a key. This chapter covers the full key-value API: conditional writes, revisions, time-to-live, prefix queries, range scans, batch operations, durability levels, and snapshot reads. By the end, you will know every tool Kahuna provides for working with key-value data.
A Kahuna key is a string. A value is a byte array (or a UTF-8 string, which the client converts to bytes). There is no schema, no type enforcement, and no size hierarchy. Every key is flat.
Keys often follow a convention of prefixed namespaces separated by
/:
users/alice
users/bob
config/feature-flags/dark-mode
orders/2024/00042
This convention is not just cosmetic. Kahuna uses the key’s prefix
(everything up to and including the last /) to determine
which partition stores the key. Keys that share a prefix route to the
same partition. This matters for prefix queries (covered later in this
chapter) and for transactions (covered in Chapter 6).
Every key in Kahuna has a revision number. The revision starts at 0 on the first write and increases by one with every subsequent write to the same key.
var client = new KahunaClient("http://localhost:2070");
KahunaKeyValue r1 = await client.SetKeyValue("counter/a", "first");
Console.WriteLine(r1.Revision); // 0
KahunaKeyValue r2 = await client.SetKeyValue("counter/a", "second");
Console.WriteLine(r2.Revision); // 1
KahunaKeyValue r3 = await client.SetKeyValue("counter/a", "third");
Console.WriteLine(r3.Revision); // 2Revisions are per-key. Writing to counter/a does not
affect the revision of counter/b.
The revision number serves two purposes:
Conflict detection. Conditional writes can check the revision before updating. If another client changed the key since you last read it, the revision will not match and the write will fail. This is optimistic concurrency control.
History. Kahuna stores previous revisions. You can read the value of a key at any past revision.
KahunaKeyValue atRev1 = await client.GetKeyValueRevision("counter/a", revision: 1);
Console.WriteLine(atRev1.ValueAsString()); // "second"If the requested revision does not exist, Success is
false and Value is null.
An unconditional SetKeyValue always overwrites the
current value. Conditional writes add a check: the write only succeeds
if a condition is met. If the condition fails, Success is
false and the key is unchanged.
Kahuna provides four conditional write modes, controlled by the
KeyValueFlags parameter.
Write only if the key does not already exist:
KahunaKeyValue result = await client.SetKeyValue(
"config/db-connection",
"Server=primary;Database=app",
flags: KeyValueFlags.SetIfNotExists
);
if (result.Success)
Console.WriteLine("Configuration key created.");
else
Console.WriteLine("Key already exists. Not overwritten.");This is useful for one-time initialization. Multiple processes can race to set the key. Exactly one will succeed.
Write only if the key already exists:
KahunaKeyValue result = await client.SetKeyValue(
"config/db-connection",
"Server=secondary;Database=app",
flags: KeyValueFlags.SetIfExists
);
if (result.Success)
Console.WriteLine("Configuration updated.");
else
Console.WriteLine("Key does not exist. Nothing to update.");Write only if the key’s current revision matches a specified value. This is the primary mechanism for optimistic concurrency control.
// Read the current value and its revision.
KahunaKeyValue current = await client.GetKeyValue("inventory/widget-stock");
Console.WriteLine($"Current stock: {current.ValueAsString()}, revision: {current.Revision}");
// Update the value, but only if nobody else changed it.
KahunaKeyValue updated = await client.TryCompareRevisionAndSetKeyValue(
"inventory/widget-stock",
"95",
compareRevision: current.Revision
);
if (updated.Success)
Console.WriteLine($"Updated to revision {updated.Revision}.");
else
Console.WriteLine("Conflict: another client modified the key. Read and retry.");The pattern is read, modify, write-if-unchanged. If another client wrote to the key between your read and your write, the revision will differ and the write will fail. Your code can then re-read and retry.
Write only if the key’s current value matches a specified byte sequence:
KahunaKeyValue result = await client.TryCompareValueAndSetKeyValue(
"state-machine/order-123",
value: "shipped",
compareValue: "paid"
);
if (result.Success)
Console.WriteLine("State transitioned from 'paid' to 'shipped'.");
else
Console.WriteLine("Current value is not 'paid'. Transition rejected.");This is useful for state machines. The transition from “paid” to “shipped” only succeeds if the current state is “paid.” If another process already moved the state to “cancelled,” the write fails.
Keys can have an expiration time. After the TTL elapses, the key is no longer readable. The TTL is specified in milliseconds.
// This key expires after 60 seconds.
await client.SetKeyValue("session/token-abc", "active", expiryTime: 60000);You can also use a TimeSpan:
await client.SetKeyValue("session/token-abc", "active", TimeSpan.FromMinutes(5));An expiry of 0 (the default) means the key does not expire.
If a key is about to expire but you need it to live longer, extend its TTL without changing its value:
await client.ExtendKeyValue("session/token-abc", expiresMs: 60000);Or using a TimeSpan:
await client.ExtendKeyValue("session/token-abc", TimeSpan.FromMinutes(5));The extension resets the expiration clock from the current time. If
the key does not exist, Success is false.
You can combine TTL with conditional write flags:
// Create a session key only if it doesn't already exist, with a 30-minute TTL.
KahunaKeyValue session = await client.SetKeyValue(
"session/user-42",
"session-data-here",
expiryTime: 1800000,
flags: KeyValueFlags.SetIfNotExists
);Every key-value operation accepts a durability parameter
with two options:
| Level | Behavior |
|---|---|
Persistent |
The write goes through Raft consensus and is replicated to a majority of nodes. This is the default. |
Ephemeral |
The write is stored in memory on the receiving node only. It is not replicated and does not survive a node restart. |
// Persistent (default): safe, replicated.
await client.SetKeyValue("config/important", "value", durability: KeyValueDurability.Persistent);
// Ephemeral: fast, local, not replicated.
await client.SetKeyValue("cache/temp-result", "value", durability: KeyValueDurability.Ephemeral);Use ephemeral durability for data you can afford to lose: caches, temporary counters, rate-limit windows. Use persistent durability for anything that must survive failures.
A read with a specific durability level only sees keys written at
that same level. A key written as Ephemeral is not visible
to a Persistent read, and vice versa.
Delete a key:
KahunaKeyValue deleted = await client.DeleteKeyValue("users/alice");
if (deleted.Success)
Console.WriteLine("Key deleted.");
else
Console.WriteLine("Key did not exist.");A delete on a non-existent key sets Success to
false.
If you only need to know whether a key exists (without fetching its
value), use ExistsKeyValue:
KahunaKeyValue exists = await client.ExistsKeyValue("users/alice");
if (exists.Success)
Console.WriteLine($"Key exists at revision {exists.Revision}.");
else
Console.WriteLine("Key does not exist.");This is lighter than GetKeyValue because the server does
not transfer the value bytes.
By default, every write archives a revision entry so you can read the key’s history. For high-throughput keys where you never need history (caches, counters, ephemeral state), you can skip revision archiving:
await client.SetKeyValueNoRevision("metrics/request-count", "4217");The revision counter still increments and conditional writes still work, but the previous value is not stored for historical reads. This reduces storage overhead for keys that change frequently.
When you need to read, write, or delete many keys at once, batch operations reduce the number of round trips.
var items = new List<KahunaSetKeyValueRequestItem>
{
new() { Key = "users/alice", Value = "admin"u8.ToArray() },
new() { Key = "users/bob", Value = "editor"u8.ToArray() },
new() { Key = "users/carol", Value = "viewer"u8.ToArray() }
};
List<KahunaKeyValue> results = await client.SetManyKeyValues(items);
foreach (KahunaKeyValue kv in results)
Console.WriteLine($"{kv.Key}: revision {kv.Revision}, success: {kv.Success}");Each item in the batch can have its own flags, expiry, and durability level.
var requests = new List<KahunaGetManyKeyValuesRequestItem>
{
new() { Key = "users/alice" },
new() { Key = "users/bob" },
new() { Key = "users/carol" }
};
List<KahunaKeyValue> results = await client.GetManyKeyValues(requests);
foreach (KahunaKeyValue kv in results)
{
if (kv.Success)
Console.WriteLine($"{kv.Key} = {kv.ValueAsString()}");
else
Console.WriteLine($"{kv.Key}: not found");
}// Simple form: pass a list of keys.
List<KahunaKeyValue> deleted = await client.DeleteManyKeyValues(
new[] { "users/alice", "users/bob", "users/carol" }
);
foreach (KahunaKeyValue kv in deleted)
Console.WriteLine($"{kv.Key}: deleted = {kv.Success}");var requests = new List<KahunaGetManyKeyValuesRequestItem>
{
new() { Key = "users/alice" },
new() { Key = "users/bob" }
};
List<KahunaKeyValue> results = await client.ExistsManyKeyValues(requests);All batch operations complete in a single round trip to the server.
Kahuna provides two ways to query keys by prefix. The difference between them is scope.
GetByBucket returns all keys that share a prefix, within
the partition that owns that prefix:
List<KahunaKeyValue> users = await client.GetByBucket(
"users",
KeyValueDurability.Persistent
);
foreach (KahunaKeyValue kv in users)
Console.WriteLine($"{kv.Key} = {kv.ValueAsString()}");Because keys with the same prefix hash to the same partition, this query contacts only one partition. It is efficient, but it only finds keys that share the exact prefix.
The result set is capped at 4,096 entries. For larger sets, use range scans (next section).
ScanAllByPrefix searches all nodes for keys that match a
prefix:
List<KahunaKeyValue> allConfigs = await client.ScanAllByPrefix(
"config",
KeyValueDurability.Persistent
);This is a broader operation. It contacts every node in the cluster.
Use it when you need to find keys across partitions. Like
GetByBucket, the result is capped at 4,096 entries.
For large data sets or fine-grained control over which keys to return, use range scans.
GetByRange returns keys within a prefix, optionally
bounded by start and end keys:
List<KahunaKeyValue> page = await client.GetByRange(
prefix: "orders/2024",
startKey: "orders/2024/00100",
startInclusive: true,
endKey: "orders/2024/00200",
endInclusive: false,
limit: 50
);This returns up to 50 keys in the range
[orders/2024/00100, orders/2024/00200) within the
orders/2024 prefix. The startInclusive and
endInclusive parameters control whether the boundary keys
are included.
If you omit startKey and endKey, the query
returns the first limit keys in the prefix.
ScanByRange returns an
IAsyncEnumerable<KahunaKeyValue> that pages through
results automatically:
await foreach (KahunaKeyValue kv in client.ScanByRange(
prefix: "logs/2024-03",
pageSize: 100))
{
Console.WriteLine($"{kv.Key}: {kv.ValueAsString()}");
}The client fetches pages of 100 entries from the server. Your code iterates over them as a continuous stream. This is the right choice when you do not know how many keys exist and want to process them one at a time without loading all results into memory.
You can also bound the scan with start and end keys:
await foreach (KahunaKeyValue kv in client.ScanByRange(
prefix: "events",
startKey: "events/2024-03-01",
endKey: "events/2024-03-31",
endInclusive: true,
pageSize: 200))
{
ProcessEvent(kv);
}By default, every read returns the latest committed value. Snapshot reads let you read the state of the key-value store at a specific point in time.
The snapshotMs parameter accepts a Unix epoch timestamp
in milliseconds. When set, the read returns the value that was current
at that moment.
// Write a value.
KahunaKeyValue v1 = await client.SetKeyValue("price/widget", "10.00");
long snapshotTime = v1.LastModified;
// Write a new value.
await client.SetKeyValue("price/widget", "12.00");
// Read the current value.
KahunaKeyValue current = await client.GetKeyValue("price/widget");
Console.WriteLine(current.ValueAsString()); // "12.00"
// Read the value at the snapshot time.
KahunaKeyValue snapshot = await client.GetKeyValue("price/widget", snapshotMs: snapshotTime);
Console.WriteLine(snapshot.ValueAsString()); // "10.00"The LastModified property on a
KahunaKeyValue is a Unix epoch timestamp in milliseconds.
You can use it as a snapshot anchor: read a key, save its
LastModified, and later re-read at that exact point.
Snapshot reads also work with GetByBucket,
ScanAllByPrefix, GetByRange, and
ScanByRange. This gives you a consistent view across
multiple keys at the same point in time.
// Read all user keys as they were at a specific moment.
List<KahunaKeyValue> usersAtSnapshot = await client.GetByBucket(
"users",
KeyValueDurability.Persistent,
snapshotMs: snapshotTime
);Snapshot reads depend on Kahuna’s MVCC (multi-version concurrency control) layer. Chapter 19 covers the internals of how snapshots work.
Every key-value operation returns a KahunaKeyValue
object. Here is a summary of its properties and methods:
| Member | Type | Description |
|---|---|---|
Key |
string |
The key that was operated on |
Success |
bool |
Whether the operation succeeded |
Revision |
long |
The key’s revision after the operation |
Value |
byte[]? |
The raw value bytes (null if not found or not fetched) |
Durability |
KeyValueDurability |
The durability level used |
TimeElapsedMs |
int |
Server-side time for the operation |
LastModified |
long |
Commit timestamp (Unix epoch ms) |
ValueAsString() |
string? |
Decode the value as UTF-8 |
ValueAsLong() |
long |
Parse the value as a long integer |
ValueAsBool() |
bool |
Parse the value as a boolean |
Extend(TimeSpan) |
Task<KahunaKeyValue> |
Extend the key’s TTL |
Delete() |
Task<KahunaKeyValue> |
Delete this key |
The Extend and Delete methods operate on
the same key and durability level that produced the result. They are
convenience methods so you can chain operations without repeating the
key name.
KahunaKeyValue session = await client.GetKeyValue("session/user-42");
if (session.Success)
{
// Extend the session by 30 more minutes.
await session.Extend(TimeSpan.FromMinutes(30));
}The command-line client supports all key-value operations. Here is a quick reference:
| Operation | Command |
|---|---|
| Set a key | kahuna-cli --set "key" --value "val" |
| Set with TTL | kahuna-cli --set "key" --value "val" --expires 60000 |
| Get a key | kahuna-cli --get "key" |
| Extend TTL | kahuna-cli --extend "key" --expires 60000 |
| Prefix query (single partition) | kahuna-cli --get-by-prefix "prefix" |
| Prefix scan (all nodes) | kahuna-cli --scan-by-prefix "prefix" |
| JSON output | add --format json to any command |
To connect to a specific node, use -c:
kahuna-cli -c "https://localhost:8082" --insecure --get "users/alice"The following example demonstrates several key-value patterns in a single program:
using Kahuna.Client;
using Kahuna.Shared.KeyValue;
var client = new KahunaClient("http://localhost:2070");
// 1. Create a key only if it doesn't exist (one-time initialization).
KahunaKeyValue init = await client.SetKeyValue(
"app/version", "1.0.0", flags: KeyValueFlags.SetIfNotExists);
Console.WriteLine($"Init: success={init.Success}, revision={init.Revision}");
// 2. Read, modify, write with optimistic concurrency.
KahunaKeyValue config = await client.GetKeyValue("app/version");
KahunaKeyValue updated = await client.TryCompareRevisionAndSetKeyValue(
"app/version", "1.1.0", compareRevision: config.Revision);
if (updated.Success)
Console.WriteLine($"Updated to {updated.Revision}");
else
Console.WriteLine("Conflict detected. Retry needed.");
// 3. State machine transition via compare-value-and-set.
await client.SetKeyValue("order/99/status", "pending");
KahunaKeyValue transition = await client.TryCompareValueAndSetKeyValue(
"order/99/status", value: "confirmed", compareValue: "pending");
Console.WriteLine($"Transition: {transition.Success}");
// 4. Batch write with TTL.
var items = new List<KahunaSetKeyValueRequestItem>
{
new() { Key = "cache/result-a", Value = "42"u8.ToArray(), ExpiresMs = 30000 },
new() { Key = "cache/result-b", Value = "99"u8.ToArray(), ExpiresMs = 30000 }
};
await client.SetManyKeyValues(items);
// 5. Prefix query.
List<KahunaKeyValue> cacheEntries = await client.GetByBucket(
"cache", KeyValueDurability.Persistent);
Console.WriteLine($"Cache entries: {cacheEntries.Count}");
// 6. Snapshot read.
KahunaKeyValue v1 = await client.SetKeyValue("price/gold", "2000");
long anchor = v1.LastModified;
await client.SetKeyValue("price/gold", "2050");
KahunaKeyValue latest = await client.GetKeyValue("price/gold");
KahunaKeyValue past = await client.GetKeyValue("price/gold", snapshotMs: anchor);
Console.WriteLine($"Latest: {latest.ValueAsString()}"); // 2050
Console.WriteLine($"At snapshot: {past.ValueAsString()}"); // 2000Kahuna’s key-value API goes beyond simple get and set. Conditional writes (SetIfNotExists, SetIfExists, CRAS, CVAS) give you optimistic concurrency control and state machine transitions without external locks. Revisions provide a per-key version history. TTL lets keys expire automatically. Prefix queries and range scans let you retrieve groups of related keys efficiently. Snapshot reads give you a consistent view of the store at a past point in time. Batch operations reduce round trips when you work with many keys at once.
The next chapter covers distributed locks: lease semantics, fencing tokens, lock contention, and what happens when a lock holder crashes.
On a single machine, a mutex is enough. A thread acquires the mutex, does its work, and releases it. The operating system guarantees that only one thread holds the mutex at a time.
In a distributed system, there is no shared memory and no single operating system. Two processes on different machines can both believe they hold “the lock” if the mechanism that coordinates them fails. This chapter explains how Kahuna’s distributed locks work, why leases and fencing tokens are necessary, and how to use them correctly.
A naive distributed lock works like this: write your name into a key. If the key is empty, you hold the lock. Delete the key when you are done.
The problem is “when you are done.” What if the lock holder crashes before it deletes the key? The lock is held forever. No other process can acquire it.
The solution is a lease: a lock with an expiration time. If the holder does not release the lock before the lease expires, the lock becomes available to other clients. This guarantees progress. A crashed process cannot block the system indefinitely.
Kahuna’s locks are lease-based. Every lock acquisition requires an expiry time. When the expiry elapses, the lock is automatically released.
The simplest lock acquisition takes a resource name and an expiry:
await using KahunaLock lockHandle = await client.GetOrCreateLock(
resource: "jobs/send-invoice",
expiry: TimeSpan.FromSeconds(30),
durability: LockDurability.Persistent
);
if (lockHandle.IsAcquired)
{
Console.WriteLine($"Lock acquired. Fencing token: {lockHandle.FencingToken}");
// Do the protected work here.
}
else
{
Console.WriteLine("Lock is held by another process.");
}GetOrCreateLock returns a KahunaLock
object. If the lock is already held by another owner,
IsAcquired is false. The call does not block or wait. It
tries once and returns immediately.
The lock implements IAsyncDisposable. When the
await using block ends, the lock is released automatically.
If the process crashes before the block ends, the lease expires and the
lock becomes available.
| Parameter | Type | Default | Description |
|---|---|---|---|
resource |
string |
(required) | The name of the resource to lock |
expiry |
TimeSpan or int (ms) |
30,000 ms | How long the lease lasts |
wait |
TimeSpan or int (ms) |
0 | How long to keep trying if the lock is held |
retry |
TimeSpan or int (ms) |
0 | How often to retry during the wait period |
durability |
LockDurability |
Persistent |
Replicated or in-memory |
Every lock acquisition generates a unique owner token (a GUID). This
token identifies the holder. Only the owner that acquired the lock can
release or extend it. You do not need to manage this token yourself; the
KahunaLock object holds it internally.
You can read the owner for debugging:
if (lockHandle.IsAcquired)
Console.WriteLine($"Owner: {lockHandle.OwnerAsString}");Leases solve the problem of a crashed lock holder. But they introduce a new problem: the stale lock holder.
Consider this scenario:
Both processes believe they are the rightful lock holder. The data is now corrupted.
Extending the lease duration does not fix this. No matter how long the lease is, a sufficiently long pause can outlast it. The fundamental problem is that process A has no way to know its lease expired.
Kahuna assigns a fencing token to every lock acquisition. The fencing token is a monotonically increasing integer that is scoped to a specific resource. Each time the lock changes hands, the fencing token increases.
Here is the sequence with fencing tokens:
The downstream system (a database, an API, a message queue) must participate in fencing. It stores the highest fencing token it has seen and rejects any write with a lower token.
jobs/a and
jobs/b produce independent token sequences.// First acquisition: token = 0
await using (KahunaLock first = await client.GetOrCreateLock("demo/fence", TimeSpan.FromSeconds(5)))
{
Console.WriteLine(first.FencingToken); // 0
}
// Second acquisition (after release): token = 1
await using (KahunaLock second = await client.GetOrCreateLock("demo/fence", TimeSpan.FromSeconds(5)))
{
Console.WriteLine(second.FencingToken); // 1
}Pass the fencing token to every downstream operation that the lock protects:
await using KahunaLock lockHandle = await client.GetOrCreateLock(
"orders/process-batch",
TimeSpan.FromSeconds(30)
);
if (lockHandle.IsAcquired)
{
long token = lockHandle.FencingToken;
// Pass the token to the database.
await db.ExecuteAsync(
"UPDATE orders SET status = 'processed' WHERE batch_id = @batch AND fence_token < @token",
new { batch = batchId, token }
);
}The database query rejects the update if a higher fencing token has already written to this batch. This prevents a stale lock holder from overwriting newer data.
Not every system supports fencing natively. For systems that do not, you can store the fencing token as a column or field and check it in your application logic before writing.
By default, GetOrCreateLock tries once. If the lock is
held, it returns immediately with IsAcquired = false. For
workloads where you want to wait until the lock becomes available, pass
wait and retry parameters:
await using KahunaLock lockHandle = await client.GetOrCreateLock(
resource: "jobs/send-invoice",
expiry: TimeSpan.FromSeconds(30),
wait: TimeSpan.FromSeconds(10),
retry: TimeSpan.FromMilliseconds(200)
);This tries to acquire the lock. If it fails, it retries every 200 milliseconds (with a small random jitter of plus or minus 50 ms) until either the lock is acquired or 10 seconds elapse.
If the wait period expires without acquiring the lock,
IsAcquired is false. The call does not throw an
exception.
The retry parameter must be greater than zero when
wait is greater than zero. Otherwise the client throws a
KahunaException.
When multiple clients compete for the same lock using wait and retry, each client retries independently. Kahuna does not queue waiters or guarantee fairness. The first client to attempt acquisition after the lock is released wins. The jitter on the retry interval reduces the chance that all clients retry at exactly the same time.
// 10 workers competing for the same lock.
var tasks = Enumerable.Range(0, 10).Select(async i =>
{
await using KahunaLock lk = await client.GetOrCreateLock(
"shared/critical-section",
expiry: TimeSpan.FromSeconds(5),
wait: TimeSpan.FromSeconds(60),
retry: TimeSpan.FromMilliseconds(100)
);
if (lk.IsAcquired)
{
Console.WriteLine($"Worker {i} acquired lock. Token: {lk.FencingToken}");
await Task.Delay(500); // Simulate work.
}
});
await Task.WhenAll(tasks);Each worker eventually acquires the lock because the lease is short (5 seconds) and the wait budget is long (60 seconds).
If your work takes longer than the original lease, extend it before it expires:
await using KahunaLock lockHandle = await client.GetOrCreateLock(
"jobs/long-running",
TimeSpan.FromSeconds(10)
);
if (lockHandle.IsAcquired)
{
// Start the work.
await ProcessFirstBatch();
// Extend the lease by another 10 seconds.
(bool extended, long token) = await lockHandle.TryExtend(TimeSpan.FromSeconds(10));
if (extended)
{
Console.WriteLine($"Lease extended. Fencing token still: {token}");
await ProcessSecondBatch();
}
else
{
Console.WriteLine("Extension failed. The lease may have expired.");
// Stop work. Another process may now hold the lock.
}
}Key details about extension:
TryExtend resets the expiration clock from the current
time.TryExtend returns
false.TryExtend explicitly.You can also extend a lock through the client directly, if you have the owner token:
(bool success, long token) = await client.TryExtendLock(
"jobs/long-running",
lockHandle.Owner,
TimeSpan.FromSeconds(10),
LockDurability.Persistent
);This is the scenario leases are designed for:
DisposeAsync call never runs because the process is
gone.No manual intervention is required. The cluster does not need to detect the crash. The lease simply expires, and the lock becomes available.
This is why lease duration matters:
A good starting point is 10 to 30 seconds for most workloads. Adjust based on how long the protected work takes and how quickly you need recovery after a crash.
The await using pattern releases the lock automatically.
You can also unlock manually:
KahunaLock lockHandle = await client.GetOrCreateLock(
"jobs/send-email",
TimeSpan.FromSeconds(30)
);
if (lockHandle.IsAcquired)
{
await DoWork();
bool released = await client.Unlock(
"jobs/send-email",
lockHandle.Owner,
LockDurability.Persistent
);
Console.WriteLine($"Released: {released}");
}Only the owner can unlock. If a different process tries to unlock with a different owner token, the call returns false.
If you do not unlock and do not dispose, the lock remains held until
the lease expires. The KahunaLock finalizer logs a warning
if the object is garbage-collected without being disposed.
You can inspect a lock’s current state without acquiring it:
KahunaLockInfo? info = await client.GetLockInfo(
"jobs/send-email",
LockDurability.Persistent
);
if (info != null)
{
Console.WriteLine($"Owner: {info.Owner}");
Console.WriteLine($"Fencing token: {info.FencingToken}");
Console.WriteLine($"Expires: {info.Expires}");
}If no lock exists for the resource, GetLockInfo returns
null.
You can also query through a KahunaLock object, even if
you did not acquire the lock:
KahunaLock lockHandle = await client.GetOrCreateLock("jobs/send-email", TimeSpan.FromSeconds(5));
KahunaLockInfo? info = await lockHandle.GetInfo();Lock info is a diagnostic tool. Do not use it to make decisions about
whether to proceed with work. Between the time you read the lock info
and the time you act on it, the lock state can change. Use
GetOrCreateLock for coordination, not
GetLockInfo.
By default, locks are persistent: the acquisition goes through Raft consensus and is replicated to a majority of nodes. This provides strong guarantees but adds latency.
For workloads where speed matters more than durability (coordinating in-memory caches, local-only rate limiting), use ephemeral locks:
await using KahunaLock lockHandle = await client.GetOrCreateLock(
"cache/rebuild",
expiry: TimeSpan.FromSeconds(5),
durability: LockDurability.Ephemeral
);Ephemeral locks are stored in memory on the receiving node only. They are faster but do not survive a node restart. If the node crashes, the lock is gone immediately (no need to wait for lease expiry, but also no replication safety).
Use ephemeral locks only when losing the lock on a node failure is acceptable.
The command-line client supports lock operations:
Acquire a lock:
kahuna-cli --lock "jobs/send-email" --owner "worker-1" --expires 10000Extend a lock:
kahuna-cli --extend-lock "jobs/send-email" --owner "worker-1" --expires 10000Release a lock:
kahuna-cli --unlock "jobs/send-email" --owner "worker-1"When using the CLI, you supply the owner name yourself. In the C# SDK, the owner is generated automatically as a GUID.
Attempt the lock once. If it fails, skip the work or report the conflict:
await using KahunaLock lk = await client.GetOrCreateLock(
"jobs/daily-report", TimeSpan.FromMinutes(5));
if (!lk.IsAcquired)
{
Console.WriteLine("Another instance is already generating the report.");
return;
}
await GenerateDailyReport();This is appropriate when duplicate work is harmless (another instance is already doing the job) or when you have an external retry mechanism (a job scheduler that retries later).
Wait for the lock to become available:
await using KahunaLock lk = await client.GetOrCreateLock(
"orders/checkout",
expiry: TimeSpan.FromSeconds(15),
wait: TimeSpan.FromSeconds(30),
retry: TimeSpan.FromMilliseconds(100)
);
if (!lk.IsAcquired)
throw new TimeoutException("Could not acquire checkout lock within 30 seconds.");
await ProcessCheckout();For work that may take longer than the initial lease:
await using KahunaLock lk = await client.GetOrCreateLock(
"etl/load-customers", TimeSpan.FromSeconds(30));
if (!lk.IsAcquired) return;
foreach (var batch in customerBatches)
{
await LoadBatch(batch);
// Extend the lease before it expires.
(bool ok, _) = await lk.TryExtend(TimeSpan.FromSeconds(30));
if (!ok)
{
Console.WriteLine("Lost the lock. Stopping work.");
return;
}
}Each iteration resets the lease clock. If the extension fails (because the lease expired between iterations), the code stops immediately to avoid conflicting with a new lock holder.
Kahuna’s distributed locks combine leases and fencing tokens to solve two problems: a crashed lock holder (the lease expires and frees the lock) and a stale lock holder (the fencing token lets downstream systems reject outdated writes). Locks can wait and retry when contended, extend their lease for long-running work, and be queried for diagnostic purposes. Ephemeral locks trade durability for speed when replication is not needed.
The next chapter covers the distributed sequencer: generating globally unique, monotonically increasing numbers across the cluster.
Many applications need unique, monotonically increasing numbers: order IDs, invoice numbers, event sequence numbers, log offsets. On a single machine, an auto-incrementing counter solves the problem. In a distributed system, there is no single machine to own the counter. Two nodes incrementing their own local counters will produce duplicates.
Kahuna’s distributed sequencer generates globally unique, monotonically increasing numbers across the entire cluster. It does this without making every allocation wait for Raft consensus, thanks to a block-based allocation scheme that amortizes the cost of consensus over many values.
A sequence is a named, persistent counter. Create one with a name:
KahunaSequence seq = await client.CreateSequence("order-ids");The sequence starts at 0 with an increment of 1. You can customize the starting value, the increment, and an optional maximum:
KahunaSequence invoiceSeq = await client.CreateSequence(
name: "invoice-numbers",
initialValue: 1000,
increment: 1,
maxValue: 999999
);This sequence starts allocating from 1001 (initialValue + increment)
and stops at 999,999. If you try to allocate past the maximum, the
client throws a KahunaException with a
MaxValueExceeded response.
Creating a sequence that already exists throws a
KahunaException with an AlreadyExists
response. Use GetSequence to check whether a sequence
exists before creating it.
The simplest operation is requesting a single value:
long id = await client.NextSequenceValue("order-ids");
Console.WriteLine(id); // 1Each call returns the next value in the sequence. Values are unique
across all clients and all nodes. Two clients calling
NextSequenceValue concurrently on the same sequence will
never receive the same number.
If you need many values at once, reserve a range:
KahunaSequenceRange range = await client.ReserveSequenceRange("order-ids", count: 100);
Console.WriteLine($"Start: {range.Start}, End: {range.End}, Count: {range.Count}");The returned range contains count consecutive values.
Your application can use them locally without making further calls to
Kahuna:
KahunaSequenceRange range = await client.ReserveSequenceRange("order-ids", count: 50);
for (long id = range.Start; id <= range.End; id++)
{
await InsertOrder(id);
}Reserving a range is more efficient than calling
NextSequenceValue in a loop because it requires only one
round trip to the server.
Understanding block allocation explains why the sequencer is fast and what trade-offs it makes.
A naive sequencer would store a counter in Raft and increment it on every request. Each request would require a Raft proposal, replication to a majority of nodes, and a durable write. At high throughput, this becomes a bottleneck: hundreds of Raft round trips per second for a single counter.
Kahuna uses a different approach. Instead of incrementing the counter by 1 on every request, the sequencer reserves a block of values in a single Raft operation.
Here is how it works:
The server stores a high-water mark for each sequence. The high-water mark is the highest value that has been reserved (not necessarily issued to clients).
When a client requests a value, the sequencer actor checks if it has any values left in its current block. If it does, it returns the next value from memory. No storage operation is needed.
When the current block is exhausted, the actor reserves a new block by bumping the high-water mark in the durable record. This is a single Raft compare-and-swap operation. The new block provides another batch of values to serve from memory.
The default block size is 1,000. This means one Raft commit is amortized over 1,000 values. The throughput of the sequencer is roughly 1,000 times higher than a naive per-value approach.
Block allocation introduces the possibility of gaps in the sequence. If a node reserves a block of 1,000 values and only issues 50 before a restart or a leadership change, the remaining 950 values in that block are never issued. The next block starts from the new high-water mark.
For example:
This is the same trade-off that PostgreSQL makes with
CACHE on sequences, or that SQL Server makes with sequence
caching. The values are unique and monotonically increasing, but they
are not contiguous.
If your application requires gap-free numbering (some financial regulations require contiguous invoice numbers), set the block size to 1:
kahuna-server --sequencer-block-size 1With a block size of 1, every allocation requires a Raft round trip. This is slower but produces no gaps. The right choice depends on your requirements.
To bound stale-leader exposure, each block has a lease duration (default: 5 seconds). If a block sits in memory longer than the lease, the sequencer revalidates it against the durable record before serving more values. This prevents a stale leader from issuing values that conflict with values issued by a new leader.
The increment parameter controls the step size between
values. With the default increment of 1, values go 1, 2, 3, 4. With an
increment of 5:
KahunaSequence seq = await client.CreateSequence(
"batch-ids", initialValue: 0, increment: 5);
long a = await client.NextSequenceValue("batch-ids"); // 5
long b = await client.NextSequenceValue("batch-ids"); // 10
long c = await client.NextSequenceValue("batch-ids"); // 15The first value issued is initialValue + increment.
Subsequent values increase by increment.
Sequences can have an optional maximum. When the sequence reaches its maximum, further allocations fail:
KahunaSequence seq = await client.CreateSequence(
"limited-ids", initialValue: 0, increment: 1, maxValue: 100);
// Allocate values until the sequence is exhausted.
for (int i = 0; i < 200; i++)
{
try
{
long val = await client.NextSequenceValue("limited-ids");
Console.WriteLine(val);
}
catch (KahunaException ex)
{
Console.WriteLine($"Sequence exhausted: {ex.Message}");
break;
}
}When the maximum is reached, the client throws a
KahunaException. The sequence does not wrap around.
If no maximum is set (the default), the sequence can grow until it
reaches long.MaxValue.
In a distributed system, a client might send a request, experience a timeout, and retry. Without protection, the retry allocates new values. The client does not know whether the original request succeeded, so it may end up with two sets of values for the same logical operation.
Kahuna solves this with idempotency keys. Pass a key with your allocation request:
long id = await client.NextSequenceValue(
"order-ids",
idempotencyKey: "checkout-request-abc123"
);If you retry with the same idempotency key, Kahuna returns the same value that was allocated on the first call. No new value is consumed. This is true even if a leadership change occurred between the first call and the retry, because the idempotent result is stored in the durable record.
Idempotent reserves work the same way:
KahunaSequenceRange range = await client.ReserveSequenceRange(
"order-ids",
count: 10,
idempotencyKey: "batch-import-42"
);Retrying with the same key and the same count returns the identical
range. If you retry with the same key but a different count, Kahuna
returns an InvalidInput error.
Idempotency entries are retained for a limited time and number:
| Setting | Default | Description |
|---|---|---|
--sequencer-idempotency-retention-max |
256 | Maximum idempotency entries per sequence |
--sequencer-idempotency-retention-ttl |
600 seconds | Time window for idempotent replay |
After an entry is evicted (by age or count), a retry with that key allocates fresh values instead of replaying the original allocation. Set these values based on how long your retry windows last.
Read the metadata of an existing sequence:
KahunaSequence? seq = await client.GetSequence("order-ids");
if (seq != null)
{
Console.WriteLine($"Name: {seq.Name}");
Console.WriteLine($"Current value: {seq.CurrentValue}");
Console.WriteLine($"Initial value: {seq.InitialValue}");
Console.WriteLine($"Increment: {seq.Increment}");
Console.WriteLine($"Max value: {seq.MaxValue}");
Console.WriteLine($"Revision: {seq.Revision}");
}The CurrentValue property is the high-water mark: the
highest value that has been reserved (by block allocation), not the last
value issued to a client. It may be higher than the last value any
client received.
If the sequence does not exist, GetSequence returns
null.
bool deleted = await client.DeleteSequence("order-ids");
if (deleted)
Console.WriteLine("Sequence deleted.");
else
Console.WriteLine("Sequence not found.");Deleting a sequence removes its durable record. If you create a sequence with the same name afterward, it starts fresh from its initial value.
| Operation | Command |
|---|---|
| Create | kahuna-cli --create-sequence "name" |
| Create with options | kahuna-cli --create-sequence "name" --initial-value 1000 --increment 5 --max-value 99999 |
| Get metadata | kahuna-cli --get-sequence "name" |
| Next value | kahuna-cli --next-sequence "name" |
| Next with idempotency | kahuna-cli --next-sequence "name" --idempotency-key "req-123" |
| Reserve range | kahuna-cli --reserve-sequence "name" --count 100 |
| Delete | kahuna-cli --delete-sequence "name" |
These server-side settings control sequencer behavior:
| Setting | Default | Description |
|---|---|---|
--sequencer-block-size |
1000 | Values reserved per Raft commit. 1 = gap-free (one commit per value). |
--sequencer-block-lease |
5 seconds | Time before a cached block is revalidated against the durable record. |
--sequencer-workers |
128 | Number of sequencer actor workers. |
--sequencer-max-sequences-per-actor |
10000 | Maximum sequences one actor keeps in memory. Least recently used sequences are evicted. |
--sequencer-idempotency-retention-max |
256 | Maximum idempotency entries per sequence record. |
--sequencer-idempotency-retention-ttl |
600 seconds | How long idempotent replays survive. |
await client.CreateSequence("orders", initialValue: 10000);
// In the order placement handler:
long orderId = await client.NextSequenceValue(
"orders",
idempotencyKey: $"place-order-{requestId}"
);
await SaveOrder(orderId, orderDetails);The idempotency key ensures that a retried placement does not consume a second order number.
await client.CreateSequence("event-log-offset");
// When appending an event:
KahunaSequenceRange batch = await client.ReserveSequenceRange("event-log-offset", count: events.Count);
long offset = batch.Start;
foreach (var evt in events)
{
evt.Offset = offset++;
await AppendEvent(evt);
}Reserving a range gives each event in the batch a unique, contiguous offset within that batch.
Multiple sequences can generate IDs for different shards:
// One sequence per shard, with non-overlapping ranges via increment and initial value.
await client.CreateSequence("ids-shard-0", initialValue: 0, increment: 4);
await client.CreateSequence("ids-shard-1", initialValue: 1, increment: 4);
await client.CreateSequence("ids-shard-2", initialValue: 2, increment: 4);
await client.CreateSequence("ids-shard-3", initialValue: 3, increment: 4);
// Shard 0 produces: 4, 8, 12, 16, ...
// Shard 1 produces: 5, 9, 13, 17, ...
// Shard 2 produces: 6, 10, 14, 18, ...
// Shard 3 produces: 7, 11, 15, 19, ...Each shard generates IDs independently. The increment of 4 and staggered initial values ensure the ranges never overlap.
The sequencer provides these guarantees:
ReserveSequenceRange(count: N) call returns exactly N
consecutive values.The sequencer does not guarantee:
--sequencer-block-size 1 for gap-free numbering at the cost
of throughput.Kahuna’s distributed sequencer generates unique, monotonically increasing numbers across the cluster. Block-based allocation amortizes Raft consensus over many values, trading contiguity for throughput. Idempotency keys protect against duplicate allocation on retries. Sequences support custom increments, maximum values, and range reservations for batch workloads.
The next chapter covers transactions: how to read and write multiple keys atomically, with snapshot isolation and conflict detection.
The previous chapters covered operations on individual keys, locks, and sequences. Each operation targets a single resource and completes in one round trip. Many real workloads need more than that. Transferring a balance between two accounts requires reading both keys, checking constraints, and writing both keys as a single atomic unit. If the process crashes after writing one key but before writing the other, the data is inconsistent.
Kahuna supports multi-key transactions with snapshot isolation. A transaction groups multiple reads and writes into a unit that either commits entirely or rolls back entirely. No partial results are visible to other clients.
Kahuna offers two transaction models:
Script transactions send a self-contained script to the server. The server parses, plans, and executes the entire script as one atomic unit. This is the simpler model and the better choice when the logic can be expressed as a short sequence of key-value operations.
Interactive transactions open a session between the client and the server. The client sends operations one at a time within the session, reads results, makes decisions in application code, and then commits or rolls back. This model is necessary when the transaction logic depends on intermediate results that only the application can evaluate.
Both models use two-phase commit (2PC) internally when the transaction touches keys on multiple partitions.
In both models, the server is the transaction coordinator. The client sends operations and a commit request, but the server drives the 2PC protocol and applies the writes. If the client crashes after it sends the commit request, the server still completes the transaction. The commit decision does not depend on the client remaining connected. This makes Kahuna transactions resilient to client failures at the critical moment.
A script transaction is a string of key-value commands that the server executes atomically. If any command fails, the entire transaction is aborted and no changes are applied.
string script = @"
SET accounts/alice '950'
SET accounts/bob '1050'
";
KahunaKeyValueTransactionResult result =
await client.ExecuteKeyValueTransactionScript(script);
Console.WriteLine(result.Type); // SetThis script writes two keys in a single atomic operation. Both writes
succeed together or fail together. The Type property on the
result indicates the outcome of the last command in the script.
Hard-coding values into a script string is inconvenient and error-prone. Use parameters to pass values at runtime:
string script = "SET @key @value";
var parameters = new List<KeyValueParameter>
{
new() { Key = "@key", Value = "accounts/alice" },
new() { Key = "@value", Value = "950" }
};
KahunaKeyValueTransactionResult result =
await client.ExecuteKeyValueTransactionScript(script, parameters: parameters);A parameter is a placeholder that starts with @. At
execution time, Kahuna replaces each placeholder with the corresponding
value from the parameter list.
ExecuteKeyValueTransactionScript returns a
KahunaKeyValueTransactionResult. This object contains the
outcome of the script:
| Property | Type | Description |
|---|---|---|
Type |
KeyValueResponseType |
The response type of the last command |
Values |
List<...> |
List of values returned by GET commands |
FirstValue |
byte[]? |
The value from the first entry in the list |
FirstValueAsString |
string? |
The first value decoded as UTF-8 |
FirstRevision |
long |
The revision from the first entry |
TimeElapsedMs |
int |
Server-side execution time |
When a script contains one or more GET commands, the returned values
appear in the Values list in order. Each entry carries the
key, value, revision, expiration time, and last modification
timestamp.
string script = @"
SET users/1 'Alice'
SET users/2 'Bob'
GET users/1
";
KahunaKeyValueTransactionResult result =
await client.ExecuteKeyValueTransactionScript(script);
Console.WriteLine(result.FirstValueAsString); // AliceEvery time you call ExecuteKeyValueTransactionScript
with a string script, the server must parse it. If you execute the same
script many times (with different parameters), you can avoid repeated
parsing by loading the script once:
KahunaTransactionScript transferScript = client.LoadTransactionScript(
"SET @from @fromBalance SET @to @toBalance"
);LoadTransactionScript computes a Blake3 hash of the
script text. On the first execution, the server parses the script and
caches the result keyed by this hash. On subsequent executions, the
server finds the cached plan and skips parsing.
Execute the pre-hashed script with Run:
KahunaKeyValueTransactionResult result = await transferScript.Run(
parameters: new()
{
new() { Key = "@from", Value = "accounts/alice" },
new() { Key = "@fromBalance", Value = "900" },
new() { Key = "@to", Value = "accounts/bob" },
new() { Key = "@toBalance", Value = "1100" }
}
);By default, script transactions run at Normal priority.
When the server is at its concurrency ceiling, you can influence which
transactions start first:
KahunaKeyValueTransactionResult result = await transferScript.Run(
priority: TransactionPriority.High,
parameters: new()
{
new() { Key = "@key", Value = "config/critical" },
new() { Key = "@value", Value = "updated" }
}
);Priority levels, from lowest to highest: Background,
Low, Normal, High,
Critical. Priority only affects admission order when the
server is saturated. Below the concurrency ceiling, all transactions
start immediately regardless of priority.
The next chapter covers the full script language in detail: variables, conditionals, loops, built-in functions, and multi-statement control flow.
When the transaction logic requires reading a value from the server, making a decision in your application, and then writing based on that decision, use an interactive transaction session.
await using KahunaTransactionSession session = await client.StartTransactionSession(
new KahunaTransactionOptions
{
Timeout = 5000,
Locking = KeyValueTransactionLocking.Pessimistic
}
);StartTransactionSession opens a session with the server.
The session receives a unique transaction ID and is pinned to a
coordinator node for its lifetime. All operations within the session are
part of the same transaction.
The session implements IAsyncDisposable. When the
await using block ends:
Pending status (no explicit
commit or rollback), it rolls back automatically.Inside a session, you can perform the same key-value operations as
the main KahunaClient, but they execute within the
transaction scope:
await using KahunaTransactionSession session = await client.StartTransactionSession(
new KahunaTransactionOptions { Timeout = 5000 }
);
KahunaKeyValue alice = await session.GetKeyValue("accounts/alice");
KahunaKeyValue bob = await session.GetKeyValue("accounts/bob");
long aliceBalance = long.Parse(alice.ValueAsString ?? "0");
long bobBalance = long.Parse(bob.ValueAsString ?? "0");
if (aliceBalance >= 100)
{
await session.SetKeyValue("accounts/alice", (aliceBalance - 100).ToString());
await session.SetKeyValue("accounts/bob", (bobBalance + 100).ToString());
await session.Commit();
}
else
{
await session.Rollback();
}This transaction reads two balances, checks a constraint in application code, and writes updated balances. The reads and writes are part of a single atomic unit. If another client modifies either account between the reads and the commit, the transaction may be aborted (depending on the locking mode and read validation settings).
The session supports these operations:
| Operation | Method |
|---|---|
| Set a key | SetKeyValue(key, value, ...) |
| Get a key | GetKeyValue(key, ...) |
| Check existence | ExistsKeyValue(key, ...) |
| Extend TTL | ExtendKeyValue(key, expiresMs, ...) |
| Delete a key | DeleteKeyValue(key, ...) |
| Delete many keys | DeleteManyKeyValues(keys, ...) |
| Get by prefix | GetByBucket(prefixKey, ...) |
| Get by range | GetByRange(prefix, startKey, ..., endKey, ...) |
| Compare-value-and-swap | TryCompareValueAndSetKeyValue(key, value, compareValue, ...) |
| Compare-revision-and-swap | TryCompareRevisionAndSetKeyValue(key, value, compareRevision, ...) |
All operations check that the session is still in
Pending status. If you try to operate on a committed,
rolled back, or aborted session, the client throws a
KahunaException.
Call Commit to finalize the transaction:
bool committed = await session.Commit();The commit drives a two-phase commit protocol across all partitions
that the transaction touched. If the commit succeeds,
committed is true and the session status moves to
Committed. All writes become visible to other clients.
If a conflict is detected (another transaction modified a key that
this transaction read or wrote), the commit throws a
KahunaException with an Aborted error code.
The session moves to the Aborted status. This is terminal:
you cannot retry a commit on an aborted session. Start a new session
instead.
If a transient error occurs (network timeout, temporary leader
unavailability), the session returns to Pending status. You
can retry the commit.
Call Rollback to discard all changes:
await session.Rollback();After a rollback, the session status is Rolledback. All
acquired locks are released and no writes from this session are
applied.
If you do not call either Commit or
Rollback, the await using block calls
Rollback automatically when the session is disposed.
A session moves through these states:
| Status | Meaning |
|---|---|
Pending |
The session is active. Operations, commit, and rollback are allowed. |
Finalizing |
A commit or rollback is in flight. No new operations are accepted.
If the finalize fails, the session returns to Pending. |
Committed |
The transaction committed. Terminal state. |
Rolledback |
The transaction rolled back. Terminal state. |
Aborted |
A commit was definitively rejected (conflict or permanent 2PC failure). Terminal state. |
Kahuna interactive sessions support two locking strategies: pessimistic (the default) and optimistic.
new KahunaTransactionOptions
{
Locking = KeyValueTransactionLocking.Pessimistic
}In pessimistic mode, the session acquires an exclusive lock on every key it reads or writes. This means:
GetKeyValue("k") acquires an exclusive lock on
k before reading.SetKeyValue("k", "v") acquires an exclusive lock on
k before writing.GetByBucket("prefix/") acquires an exclusive prefix
lock before scanning.GetByRange(...) acquires an exclusive range lock before
scanning.No other transaction can read or write a locked key until this transaction commits or rolls back. This prevents conflicts at the cost of concurrency: if two transactions touch the same key, one waits (or aborts) while the other holds the lock.
Pessimistic locking is the safer default. Use it when conflicts are common or when you cannot tolerate aborted transactions.
new KahunaTransactionOptions
{
Locking = KeyValueTransactionLocking.Optimistic
}In optimistic mode, the session acquires exclusive locks only on writes, not on reads:
GetKeyValue("k") reads without locking.SetKeyValue("k", "v") acquires an exclusive lock on
k before writing.This allows higher concurrency for read-heavy workloads. Multiple transactions can read the same keys simultaneously. Conflicts are detected at commit time: if a key that this transaction read was modified by another transaction after the read, the commit is rejected.
Optimistic locking works best when conflicts are rare. If conflicts are frequent, transactions abort and retry repeatedly, which wastes work.
By default (ReadValidation.None), the commit does not
check whether keys that the transaction read were modified by other
transactions. This is efficient but allows a class of anomaly called
write-skew.
Consider two doctors on call. A business rule says at least one doctor must remain on call. Both doctors check the on-call roster, see the other is on call, and each removes themselves. Both transactions commit because neither wrote to the same key. The result: zero doctors on call.
new KahunaTransactionOptions
{
Locking = KeyValueTransactionLocking.Optimistic,
ReadValidation = ReadValidation.TrackAndValidate
}With TrackAndValidate, the session records every key it
reads (the read set). At commit time, the server checks whether any key
in the read set was modified after this transaction read it. If so, the
commit is aborted. This prevents write-skew at the cost of additional
validation work during commit.
TrackAndValidate is most useful with optimistic locking.
Pessimistic locking already prevents concurrent modifications through
exclusive locks, so read validation adds little value in that mode.
Many applications follow the same pattern: start a session, do work, commit, and retry on conflict. Kahuna provides a helper that encapsulates this pattern:
await client.RetryableTransaction(
new KahunaTransactionOptions
{
Timeout = 5000,
Locking = KeyValueTransactionLocking.Pessimistic
},
async (session, ct) =>
{
KahunaKeyValue counter = await session.GetKeyValue("stats/visits");
long count = long.Parse(counter.ValueAsString ?? "0");
await session.SetKeyValue("stats/visits", (count + 1).ToString());
await session.Commit(ct);
}
);RetryableTransaction starts a session, runs your
callback, and handles retry logic automatically. If the callback throws
a KahunaException with an Aborted,
MustRetry, or AlreadyLocked error code, the
method waits and retries with a new session.
The retry strategy uses decorrelated jitter backoff:
KahunaException with an Aborted error
code.Any exception that is not Aborted,
MustRetry, or AlreadyLocked propagates
immediately without retry. This includes application logic errors,
cancellation, and permanent failures.
| Option | Type | Default | Description |
|---|---|---|---|
Timeout |
int |
5000 ms | How long the transaction may live once started. The server releases the session and its locks when the timeout elapses. |
AdmissionWaitMs |
int |
0 (server default) | How long the client waits for an admission slot when the server is
at its session ceiling. If the wait is exhausted, the call fails with
AdmissionRefused. |
Locking |
KeyValueTransactionLocking |
Pessimistic |
Locking strategy: Pessimistic locks on reads and
writes, Optimistic locks on writes only. |
AutoCommit |
bool |
true |
Whether the server should auto-commit the transaction on dispose. |
ReadValidation |
ReadValidation |
None |
None skips read-set validation.
TrackAndValidate checks for write-skew at commit time. |
DecisionDurability |
DecisionDurability |
BestEffort |
BestEffort returns the outcome to the client before the
2PC decision record is durably replicated. Durable waits
until the decision is committed through Raft. |
ReadTimestamp |
HLCTimestamp |
0 (latest) | A snapshot timestamp for reads. Zero means reads observe the current committed state. A non-zero value pins reads to that point in time. |
Priority |
TransactionPriority |
Normal |
Admission priority when the server is saturated. Levels:
Background, Low, Normal,
High, Critical. |
The DecisionDurability option controls how the server
handles the 2PC decision record:
BestEffort (default): The server tells the client the outcome as soon as the two-phase commit protocol completes in memory. The decision is persisted asynchronously. This is faster, but in a narrow crash window the decision could be lost and the transaction outcome becomes unknown.
Durable: The server persists the decision record through Raft consensus before returning the outcome to the client. This adds latency but guarantees that the outcome survives a crash.
For most workloads, BestEffort is sufficient. Use
Durable when you need an external guarantee that a
committed transaction will remain committed even if the coordinator node
crashes immediately after the commit response.
The ReadTimestamp option pins all reads in the session
to a specific point in time. This is useful for reporting queries that
must see a consistent snapshot while other transactions continue to
write:
await using KahunaTransactionSession session = await client.StartTransactionSession(
new KahunaTransactionOptions
{
Timeout = 10000,
Locking = KeyValueTransactionLocking.Optimistic,
ReadTimestamp = snapshotTimestamp
}
);
KahunaKeyValue balance = await session.GetKeyValue("accounts/alice");When ReadTimestamp is non-zero, the server serves the
value as of that timestamp without recording a read dependency. This
means the read does not participate in conflict detection, because it
reads historical data that cannot be changed.
The most common transaction pattern: read a value, compute a new value, and write it back.
await client.RetryableTransaction(
new KahunaTransactionOptions { Timeout = 5000 },
async (session, ct) =>
{
KahunaKeyValue item = await session.GetKeyValue("inventory/widget-a");
int stock = int.Parse(item.ValueAsString ?? "0");
if (stock < 1)
throw new InvalidOperationException("Out of stock.");
await session.SetKeyValue("inventory/widget-a", (stock - 1).ToString());
await session.Commit(ct);
}
);Wrapping this in RetryableTransaction ensures that if
another client modifies the same key concurrently, the transaction
retries with fresh data.
Move a value from one key to another atomically:
await client.RetryableTransaction(
new KahunaTransactionOptions { Timeout = 5000 },
async (session, ct) =>
{
KahunaKeyValue from = await session.GetKeyValue("accounts/alice");
KahunaKeyValue to = await session.GetKeyValue("accounts/bob");
long fromBalance = long.Parse(from.ValueAsString ?? "0");
long toBalance = long.Parse(to.ValueAsString ?? "0");
long amount = 100;
if (fromBalance < amount)
throw new InvalidOperationException("Insufficient funds.");
await session.SetKeyValue("accounts/alice", (fromBalance - amount).ToString());
await session.SetKeyValue("accounts/bob", (toBalance + amount).ToString());
await session.Commit(ct);
}
);If accounts/alice and accounts/bob live on
different partitions, Kahuna uses 2PC to commit both writes
atomically.
Insert a key only if it does not already exist, as part of a larger transaction:
await using KahunaTransactionSession session = await client.StartTransactionSession(
new KahunaTransactionOptions { Timeout = 5000 }
);
KahunaKeyValue existing = await session.GetKeyValue("users/alice@example.com");
if (existing.Success)
{
Console.WriteLine("User already exists.");
await session.Rollback();
}
else
{
await session.SetKeyValue("users/alice@example.com", "Alice");
await session.SetKeyValue("user-count", "1");
await session.Commit();
}Kahuna provides two transaction models. Script transactions send a self-contained script to the server for atomic execution. Interactive transactions open a session and let the client make decisions based on intermediate results. Both models use 2PC for multi-partition atomicity.
Pessimistic locking (the default) acquires exclusive locks on every
read and write, preventing conflicts at the cost of concurrency.
Optimistic locking acquires locks only on writes, allowing higher
concurrency but detecting conflicts at commit time. Read validation with
TrackAndValidate prevents write-skew anomalies by checking
the read set at commit time.
RetryableTransaction wraps the common start, work,
commit, retry loop with decorrelated jitter backoff.
The next chapter covers the transaction script language in full: variables, conditionals, loops, built-in functions, and error handling.
Chapter 6 introduced script transactions: self-contained programs that the server executes atomically. This chapter is the complete reference for the script language. It covers the syntax, data types, commands, control flow, expressions, built-in functions, and transaction options.
The Kahuna script language is a small, domain-specific language designed for one purpose: reading and writing key-value data inside a transaction. It is not a general-purpose programming language. It has no file I/O, no networking, no user-defined functions, and no classes. What it does have is direct access to Kahuna’s key-value operations, conditional logic, loops, and built-in functions for type checking and data manipulation.
Scripts are case-insensitive. SET, set, and
Set are the same command. String literals use single quotes
('hello') or double quotes ("hello").
Identifiers that collide with reserved words can be escaped with
backticks (`delete`).
A script is a sequence of statements. The server parses the script
into an abstract syntax tree, executes the statements in order, and
returns the result of the last statement (or the value passed to
RETURN).
The language supports five data types:
| Type | Examples | Notes |
|---|---|---|
| Integer | 42, -7, 0 |
64-bit signed integer (long) |
| Float | 3.14, -0.5, 1.0 |
64-bit double-precision floating point |
| String | 'hello', "world" |
UTF-8 strings |
| Boolean | true, false |
Case-insensitive |
| Null | null |
Represents the absence of a value |
Arrays are created with the range operator (1..10) or
returned by commands like GET BY BUCKET and
SCAN BY PREFIX. You cannot construct an array literal
directly.
Use LET to assign a value to a variable:
LET x = 42
LET name = 'Alice'
LET total = x + 10
LET found = true
Variables are dynamically typed. You can reassign a variable to a different type:
LET x = 42
LET x = 'now a string'
Variable names follow identifier rules: letters, digits, and underscores, starting with a letter or underscore.
SET writes a value to a key:
SET mykey 'hello world'
The key can be an identifier, a string literal, or a placeholder:
SET 'users/alice' 'active'
SET @key @value
Flags modify the behavior of SET. You can combine
multiple flags on one command.
NX (Not Exists): Write only if the key does not exist yet.
SET mykey 'first' NX
If mykey already exists, the SET does not modify it.
XX (Exists): Write only if the key already exists.
SET mykey 'updated' XX
If mykey does not exist, the SET does nothing.
CMP (Compare Value): Write only if the current value equals the given expression.
SET counter 'closed' CMP 'open'
This sets counter to 'closed' only if its
current value is 'open'.
CMPREV (Compare Revision): Write only if the current revision equals the given number.
SET config 'new-value' CMPREV 5
This sets config only if its revision is exactly 5. This
is useful for optimistic concurrency: read the revision, do your work,
then write only if nobody else changed the key.
EX (Expires): Set a TTL in milliseconds.
SET session 'token-abc' EX 30000
The key expires and is deleted after 30 seconds.
NOREV (No Revision): Do not track the revision for this write.
SET cache/item 'data' NOREV
This is useful for cache-like workloads where you do not need version tracking.
Combining flags:
SET mykey 'value' NX EX 60000
This creates the key only if it does not exist, with a 60-second TTL.
ESET is the ephemeral variant of SET. It
works the same way but stores the value in memory only, without Raft
replication. Ephemeral data does not survive a node restart.
ESET cache/user 'data' EX 5000
ESET supports all the same flags as SET:
NX, XX, CMP, CMPREV,
EX, NOREV.
GET reads a value by key:
GET mykey
To capture the result in a variable, use LET:
LET value = GET mykey
Read the value at a specific revision number:
LET old = GET mykey AT 3
This returns the value that mykey had at revision 3.
Read the value as it was at a specific point in time (Unix timestamp in milliseconds):
LET snapshot = GET mykey AS OF 1700000000000
This returns the value that mykey had at the given
timestamp.
EGET reads from ephemeral storage:
LET cached = EGET cache/user
EGET supports the same AT and
AS OF variants as GET.
Check whether a key exists:
LET found = EXISTS mykey
EXISTS also supports AT (revision) and
AS OF (timestamp) variants.
Check whether an ephemeral key exists:
LET found = EEXISTS cache/user
Delete a key:
DELETE mykey
Delete an ephemeral key:
EDELETE cache/user
Extend the TTL of a key by a given number of milliseconds:
EXTEND mykey 30000
This resets the expiry clock. The key will live for another 30 seconds from now.
Extend the TTL of an ephemeral key:
EEXTEND cache/user 5000
Retrieve all keys that share a common prefix (bucket). The prefix is
everything up to and including the last / in the key
name:
LET items = GET BY BUCKET 'users/'
This returns an array of key-value results for all keys whose names
start with users/. You can iterate over the results with a
FOR loop.
GET BY BUCKET supports AS OF for snapshot
reads:
LET items = GET BY BUCKET 'users/' AS OF 1700000000000
The ephemeral variant:
LET items = EGET BY BUCKET 'cache/'
Scan all keys that match a given prefix:
LET results = SCAN BY PREFIX 'config/'
Like GET BY BUCKET, this returns an array. It supports
AS OF for snapshot reads.
The ephemeral variant:
LET results = ESCAN BY PREFIX 'cache/config/'
Placeholders let you pass values into a script from the calling
application. A placeholder starts with @:
SET @key @value EX @ttl
In C#, pass placeholders as a list of KeyValueParameter
objects:
string script = "SET @key @value EX @ttl";
var result = await client.ExecuteKeyValueTransactionScript(
script,
parameters: [
new() { Key = "@key", Value = "users/alice" },
new() { Key = "@value", Value = "active" },
new() { Key = "@ttl", Value = "30000" }
]
);Placeholders prevent injection and let you reuse the same compiled
script with different values. When you use
LoadTransactionScript (covered in Chapter 6), the server
caches the parsed AST and reuses it across calls. Only the placeholder
values change.
The language supports arithmetic, comparison, and logical expressions.
LET a = 10 + 5 // 15
LET b = 10 - 3 // 7
LET c = 4 * 3 // 12
LET d = 10 / 2 // 5
Arithmetic works on integers and floats. Mixing types promotes the result to float.
LET eq = (x = 5) // equality (single = or ==)
LET neq = (x != 5) // not equal (or <>)
LET lt = (x < 10) // less than
LET gt = (x > 0) // greater than
LET lte = (x <= 100) // less than or equal
LET gte = (x >= 1) // greater than or equal
Both = and == test equality. Both
!= and <> test inequality.
LET both = (a > 0) && (b > 0) // AND
LET either = (a > 0) || (b > 0) // OR
LET no = !(a > 0) // NOT
You can also use the word forms: AND, OR,
NOT.
LET both = (a > 0) AND (b > 0)
The .. operator creates an array of integers:
LET nums = 1..10
This creates an array
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].
Access array elements by index (zero-based):
LET nums = 1..5
LET first = nums[0] // 1
LET third = nums[2] // 3
NOT SET: Evaluates to a sentinel that indicates a key was never written. Use it to check whether a GET returned no data:
LET value = GET mykey
IF value = NOT SET THEN
SET mykey 'default'
END
NOT FOUND: Similar to NOT SET,
indicates the key was not found:
LET value = GET mykey
IF value = NOT FOUND THEN
SET mykey 'initialized'
END
Conditional execution:
LET status = GET account/status
IF status = 'active' THEN
SET account/last-login current_time()
END
With an else branch:
LET balance = GET account/balance
IF to_long(balance) >= 100 THEN
SET account/balance to_string(to_long(balance) - 100)
ELSE
THROW 'Insufficient balance'
END
Conditions can use any expression. Nested IF statements
are allowed.
Loop over a range or an array:
FOR i IN 1..5 DO
SET concat('key/', to_string(i)) to_string(i * 10)
END
This creates five keys: key/1 through key/5
with values 10 through 50.
You can loop over the results of a bucket query:
LET items = GET BY BUCKET 'users/'
FOR item IN items DO
SET concat('backup/', item) item
END
When a script contains multiple statements without a
BEGIN block, the server wraps the entire script in an
auto-commit transaction. If any statement fails, the entire script rolls
back.
SET account/a '900'
SET account/b '1100'
Both writes succeed together or fail together.
Use BEGIN and END to define a transaction
block explicitly. Inside the block, you can use COMMIT or
ROLLBACK:
BEGIN
LET balance = GET account/a
LET amount = 100
IF to_long(balance) >= amount THEN
SET account/a to_string(to_long(balance) - amount)
LET target = GET account/b
SET account/b to_string(to_long(target) + amount)
COMMIT
ELSE
ROLLBACK
END
END
If you call ROLLBACK, all writes inside the
BEGIN block are discarded. If you call COMMIT,
the writes become durable. If you reach END without calling
either, the transaction commits automatically.
BEGIN accepts options in parentheses:
BEGIN (locking = optimistic, timeout = 5000)
LET a = GET account/a
LET b = GET account/b
SET account/a to_string(to_long(a) - 50)
SET account/b to_string(to_long(b) + 50)
END
Available options:
| Option | Values | Default | Description |
|---|---|---|---|
locking |
pessimistic, optimistic |
pessimistic |
Lock acquisition strategy |
autoCommit |
true, false |
true |
Commit automatically when the block ends without an explicit COMMIT or ROLLBACK |
asyncRelease |
true, false |
false |
Release locks asynchronously after commit |
timeout |
integer (ms) | Server default | Maximum execution time for the transaction |
admissionWait |
integer (ms) | Server default | Maximum time to wait for a transaction slot |
snapshot |
integer (Unix ms) | 0 (disabled) | Read all keys as of this timestamp |
priority |
background, low, normal,
high, critical |
normal |
Transaction scheduling priority |
RETURN ends the script and returns a value to the
caller:
LET x = GET mykey
IF x = NOT SET THEN
RETURN 'not found'
END
RETURN concat('found: ', x)
Without a RETURN, the script returns the result of the
last executed statement.
RETURN without a value ends the script immediately:
LET x = GET mykey
IF x = NOT SET THEN
RETURN
END
SET mykey to_string(to_long(x) + 1)
SLEEP pauses execution for a given number of
milliseconds:
SLEEP 1000
Use SLEEP with caution. It holds the transaction open
during the pause. The primary use case is testing and debugging, not
production workloads.
THROW raises an error and aborts the script:
LET balance = GET account/balance
IF to_long(balance) < 0 THEN
THROW 'Balance cannot be negative'
END
The error message is returned to the caller in the transaction
result’s Reason field.
The language provides built-in functions for type checking, type conversion, string manipulation, math, and metadata access.
These functions test the type of a value and return a boolean:
| Function | Description |
|---|---|
is_long(x) / is_int(x) /
is_integer(x) |
True if x is an integer |
is_float(x) / is_double(x) |
True if x is a float |
is_string(x) / is_str(x) |
True if x is a string |
is_bool(x) / is_boolean(x) |
True if x is a boolean |
is_null(x) |
True if x is null |
is_array(x) |
True if x is an array |
LET x = 42
RETURN is_long(x) // true
LET y = 'hello'
RETURN is_string(y) // true
LET z = 1..10
RETURN is_array(z) // true
These functions convert a value from one type to another:
| Function | Description |
|---|---|
to_long(x) / to_int(x) /
to_integer(x) / to_number(x) |
Convert to integer |
to_float(x) / to_double(x) |
Convert to float |
to_string(x) / to_str(x) |
Convert to string |
to_bool(x) / to_boolean(x) |
Convert to boolean |
to_json(x) |
Serialize to a JSON string |
Values stored in Kahuna are byte arrays. When you read a value with
GET, the result is a string. To do arithmetic on it,
convert it to an integer or float first:
LET raw = GET counter
LET n = to_long(raw)
SET counter to_string(n + 1)
Conversion rules:
to_long(1.7) truncates to 1.to_long(true) returns 1.
to_long(false) returns 0.to_float(true) returns 1.
to_float(false) returns 0.to_bool(0) returns false.
to_bool(1) returns true.to_string(null) returns an empty string.to_json works on integers, floats, strings, and arrays.
It produces a JSON representation of the value.| Function | Description |
|---|---|
upper(x) |
Convert string to uppercase |
lower(x) |
Convert string to lowercase |
concat(a, b) |
Concatenate two strings |
len(x) / length(x) |
Return the length of a string |
LET name = 'Alice'
RETURN upper(name) // 'ALICE'
RETURN lower(name) // 'alice'
RETURN concat('Hi, ', name) // 'Hi, Alice'
RETURN len(name) // 5
concat takes exactly two string arguments. To
concatenate more than two values, nest the calls:
LET full = concat(concat(first, ' '), last)
| Function | Description |
|---|---|
abs(x) |
Absolute value |
pow(x, y) |
x raised to the power y |
round(x) |
Round to nearest integer |
ceil(x) |
Round up |
floor(x) |
Round down |
min(a, b) |
Smaller of two values |
max(a, b) |
Larger of two values |
LET a = abs(-5) // 5
LET b = pow(2, 10) // 1024
LET c = round(3.7) // 4
LET d = ceil(3.1) // 4
LET e = floor(3.9) // 3
LET f = min(10, 20) // 10
LET g = max(10, 20) // 20
| Function | Description |
|---|---|
revision(x) / rev(x) |
Return the revision number of a GET result |
expires(x) |
Return the expiry timestamp of a GET result |
current_time() |
Return the current UTC time as Unix milliseconds |
count(x) |
Return the number of elements in an array |
LET value = GET mykey
LET r = revision(value)
LET e = expires(value)
LET now = current_time()
revision and expires are useful for
conditional logic based on a key’s metadata:
LET value = GET mykey
IF revision(value) > 10 THEN
SET mykey 'stable'
END
count works only on arrays:
LET items = GET BY BUCKET 'users/'
LET total = count(items)
RETURN to_string(total)
This example combines many features: variables, GET, SET with conditions, type conversion, explicit transactions, and error handling.
BEGIN (locking = pessimistic, timeout = 5000)
LET source_balance = GET @source
LET target_balance = GET @target
IF source_balance = NOT SET THEN
THROW concat('Source account not found: ', @source)
END
IF target_balance = NOT SET THEN
THROW concat('Target account not found: ', @target)
END
LET amount = to_long(@amount)
LET src = to_long(source_balance)
LET tgt = to_long(target_balance)
IF src < amount THEN
THROW 'Insufficient balance'
END
SET @source to_string(src - amount)
SET @target to_string(tgt + amount)
COMMIT
END
Call this script from C#:
var result = await client.ExecuteKeyValueTransactionScript(
script,
parameters: [
new() { Key = "@source", Value = "account/alice" },
new() { Key = "@target", Value = "account/bob" },
new() { Key = "@amount", Value = "500" }
]
);The transaction reads both balances, checks the source has enough funds, updates both accounts, and commits. If any step fails or the source balance is too low, the script throws and no writes take effect.
This example uses a FOR loop to initialize a set of
keys:
FOR i IN 0..9 DO
LET key = concat('sensor/', to_string(i))
LET exists_result = EXISTS key
IF exists_result = NOT SET THEN
SET key '0' EX 60000
END
END
This creates keys sensor/0 through
sensor/9, each with a 60-second TTL, but only if the key
does not exist yet.
Read the state of several keys as they were at a specific moment:
BEGIN (snapshot = @timestamp)
LET a = GET account/a
LET b = GET account/b
LET c = GET account/c
LET total = to_long(a) + to_long(b) + to_long(c)
RETURN to_string(total)
END
The snapshot option ensures all three reads see a consistent view of the data at the given timestamp, even if the keys changed after that point.
When you send a script to the server, the server parses it into an
AST (abstract syntax tree). Parsing is fast, but for scripts that run
frequently, you can avoid the overhead by using
LoadTransactionScript (covered in Chapter 6). The server
hashes the script with Blake3, caches the AST, and reuses it on
subsequent calls with the same hash. Placeholder values change between
calls, but the structure stays cached.
The server evicts cached scripts when they have not been used within
the cache TTL. The TTL is configured with
--script-cache-expiration (default: 600 seconds).
These words are reserved by the language. You cannot use them as bare key names or variable names without backtick escaping:
SET, GET, LET,
IF, THEN, ELSE, END,
FOR, DO, IN, BEGIN,
COMMIT, ROLLBACK, RETURN,
SLEEP, DELETE, DEL,
EXTEND, EXISTS, ESET,
EGET, EDELETE, EDEL,
EEXTEND, EEXISTS, TRUE,
FALSE, NULL, NX, XX,
EX, CMP, CMPREV,
NOREV, THROW, FOUND,
NOT, AT, AS, OF,
SCAN, ESCAN, BY,
BUCKET, PREFIX, AND,
OR
If your key name collides with a reserved word, use a string literal or backtick escaping:
SET 'delete' 'some value'
SET `delete` 'some value'
Operators follow this precedence, from lowest to highest:
| Precedence | Operator | Description |
|---|---|---|
| 1 (lowest) | .. |
Range |
| 2 | OR, \|\| |
Logical OR |
| 3 | AND, && |
Logical AND |
| 4 | =, ==, !=,
<> |
Equality |
| 5 | <, >, <=,
>= |
Comparison |
| 6 | +, - |
Addition, subtraction |
| 7 | *, / |
Multiplication, division |
| 8 | !, NOT |
Logical NOT (right-associative) |
| 9 (highest) | [] |
Array indexing |
Use parentheses to override the default order:
LET result = (a + b) * c
The Kahuna script language provides the building blocks for
server-side transaction logic: key-value commands with conditional
flags, variables, arithmetic and logical expressions, control flow with
IF and FOR, explicit transaction blocks with
configurable options, and built-in functions for type checking,
conversion, string manipulation, and math. Placeholders separate data
from logic, and script caching amortizes parsing cost.
Each persistent command (SET, GET,
EXISTS, DELETE, EXTEND) has an
ephemeral counterpart (ESET, EGET,
EEXISTS, EDELETE, EEXTEND) for
in-memory workloads. Bulk reads use GET BY BUCKET and
SCAN BY PREFIX.
The next chapter covers real-world patterns: how to combine these primitives to build distributed coordination workflows, leader election, and idempotent processing.
Many distributed applications need exactly one active process to do a specific job. A scheduler that sends reminder emails, a worker that processes a payment queue, a service that aggregates metrics: each of these must run on one node at a time. If two instances run simultaneously, they send duplicate emails, process payments twice, or produce incorrect aggregates.
On a single machine, this is simple. You run one process. If it crashes, a supervisor restarts it. In a distributed system, you run multiple instances for availability, and you need a way to decide which one is the active leader. The others stand by and take over if the leader fails.
This chapter shows how to build leader election and job coordination patterns with Kahuna’s locks, fencing tokens, and key-value operations.
A distributed lock is a natural fit for leader election. The process that holds the lock is the leader. All other processes are followers. When the leader fails, the lock’s lease expires and a follower acquires it.
Here is the simplest version:
while (!stoppingToken.IsCancellationRequested)
{
await using KahunaLock lockHandle = await client.GetOrCreateLock(
resource: "leader/email-scheduler",
expiry: TimeSpan.FromSeconds(15),
wait: TimeSpan.FromSeconds(10),
retry: TimeSpan.FromMilliseconds(500),
durability: LockDurability.Persistent,
cancellationToken: stoppingToken
);
if (lockHandle.IsAcquired)
{
Console.WriteLine($"I am the leader. Fencing token: {lockHandle.FencingToken}");
await RunSchedulerLoop(lockHandle, stoppingToken);
}
else
{
Console.WriteLine("Another instance is the leader. Retrying...");
await Task.Delay(2000, stoppingToken);
}
}Each instance tries to acquire the lock. The winner becomes the leader and runs the scheduler loop. The losers wait and try again. If the leader crashes, the 15-second lease expires and a follower acquires the lock on the next attempt.
This version works, but it has a problem. The leader does work for up to 15 seconds and then the lock expires. If the work takes longer than 15 seconds, another instance acquires the lock and both instances run simultaneously.
The solution is a heartbeat: the leader renews its lease periodically while it does work. The lease duration is a safety net for crashes, not a cap on how long the leader can run.
async Task RunAsLeader(KahunaLock lockHandle, CancellationToken stoppingToken)
{
using CancellationTokenSource leaderCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
Task renewalTask = RunHeartbeat(lockHandle, leaderCts);
try
{
await DoLeaderWork(lockHandle.FencingToken, leaderCts.Token);
}
finally
{
leaderCts.Cancel();
await renewalTask;
}
}
async Task RunHeartbeat(KahunaLock lockHandle, CancellationTokenSource leaderCts)
{
try
{
while (!leaderCts.Token.IsCancellationRequested)
{
await Task.Delay(5000, leaderCts.Token);
(bool extended, long token) = await lockHandle.TryExtend(TimeSpan.FromSeconds(15));
if (!extended)
{
Console.WriteLine("Lost leadership. Lease renewal failed.");
leaderCts.Cancel();
return;
}
}
}
catch (OperationCanceledException)
{
// Normal shutdown.
}
}The heartbeat task renews the lease every 5 seconds. The lease duration is 15 seconds. This gives the leader three chances to renew before the lease expires. If a single renewal fails (network blip, temporary leader unavailability), the leader has 10 more seconds before it loses the lock.
A good rule of thumb: renew at one-third of the lease duration. This gives you two full renewal attempts as a buffer.
If TryExtend returns false, one of these things
occurred:
In both cases, the leader must stop its work immediately. The
CancellationTokenSource propagates the cancellation to all
ongoing work. Any downstream operation that respects the cancellation
token will stop.
The leader should not attempt to re-acquire the lock in the same iteration. It should release the lock handle, return to the outer loop, and compete for leadership again from scratch.
Here is the complete pattern that combines acquisition, heartbeat renewal, and graceful shutdown:
public class LeaderElectionService : BackgroundService
{
private readonly KahunaClient _client;
private readonly string _resource;
private readonly TimeSpan _leaseDuration;
private readonly TimeSpan _renewalInterval;
public LeaderElectionService(KahunaClient client, string resource)
{
_client = client;
_resource = resource;
_leaseDuration = TimeSpan.FromSeconds(15);
_renewalInterval = TimeSpan.FromSeconds(5);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await using KahunaLock lockHandle = await _client.GetOrCreateLock(
resource: _resource,
expiry: _leaseDuration,
wait: TimeSpan.FromSeconds(30),
retry: TimeSpan.FromMilliseconds(500),
durability: LockDurability.Persistent,
cancellationToken: stoppingToken
);
if (!lockHandle.IsAcquired)
{
await Task.Delay(2000, stoppingToken);
continue;
}
Console.WriteLine($"Elected as leader. Token: {lockHandle.FencingToken}");
using CancellationTokenSource leaderCts =
CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
Task heartbeat = RunHeartbeat(lockHandle, leaderCts);
try
{
await DoLeaderWork(lockHandle.FencingToken, leaderCts.Token);
}
catch (OperationCanceledException) when (leaderCts.IsCancellationRequested)
{
Console.WriteLine("Leadership ended.");
}
finally
{
leaderCts.Cancel();
await heartbeat;
}
}
}
private async Task RunHeartbeat(KahunaLock lockHandle, CancellationTokenSource leaderCts)
{
try
{
while (!leaderCts.Token.IsCancellationRequested)
{
await Task.Delay(_renewalInterval, leaderCts.Token);
(bool ok, _) = await lockHandle.TryExtend(_leaseDuration);
if (!ok)
{
leaderCts.Cancel();
return;
}
}
}
catch (OperationCanceledException) { }
}
private async Task DoLeaderWork(long fencingToken, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
await ProcessNextBatch(fencingToken, ct);
}
}
private async Task ProcessNextBatch(long fencingToken, CancellationToken ct)
{
// Application-specific work goes here.
// Pass fencingToken to downstream systems.
await Task.Delay(1000, ct);
}
}This pattern uses .NET’s BackgroundService as a host.
You can register it in your dependency injection container. It runs for
the lifetime of the application.
A process in this pattern moves through three states:
The transitions are:
GetOrCreateLock returns
IsAcquired = true.TryExtend returns false, or the
application requests shutdown.GetOrCreateLock again.Chapter 4 introduced fencing tokens. In leader election, they are essential. Here is why.
Consider this sequence of events:
The result: duplicate processing. Both processes act as leader simultaneously.
This implementation looks correct but is broken:
// INCORRECT: no fencing token check
async Task ProcessJob(KahunaClient client, string jobId)
{
KahunaKeyValue job = await client.GetKeyValue($"jobs/{jobId}");
if (job.ValueAsString == "pending")
{
await ExecuteJob(jobId);
await client.SetKeyValue($"jobs/{jobId}", "completed");
}
}A stale leader (process A) can execute this code after its lease expired. It reads the job, sees “pending”, executes it, and marks it “completed.” Meanwhile, the new leader (process B) also reads the same job as “pending” before A marks it complete. Both execute the job.
Pass the fencing token to every operation that the leader performs. The downstream system rejects writes from stale leaders:
async Task ProcessJob(KahunaClient client, long fencingToken, string jobId)
{
KahunaKeyValue job = await client.GetKeyValue($"jobs/{jobId}");
if (job.ValueAsString == "pending")
{
await ExecuteJob(jobId);
// Store the fencing token alongside the job status.
await client.SetKeyValue(
$"jobs/{jobId}",
$"completed|token={fencingToken}"
);
}
}For systems that do not natively support fencing (most databases and message queues), you can implement a fence check at the application level:
async Task<bool> TryClaimJob(KahunaClient client, long fencingToken, string jobId)
{
// Use compare-and-swap to claim the job atomically.
KahunaKeyValue result = await client.SetKeyValue(
$"jobs/{jobId}",
$"running|token={fencingToken}",
flags: KeyValueFlags.SetIfEqualToValue,
durability: KeyValueDurability.Persistent
);
return result.Success;
}If a stale leader with token 5 tries to claim a job that the new leader (token 6) already claimed, the compare-and-swap fails because the current value does not match “pending.”
Sometimes other services need to know who the current leader is. You can publish leader identity in a key-value entry alongside the lock:
if (lockHandle.IsAcquired)
{
string identity = JsonSerializer.Serialize(new
{
host = Environment.MachineName,
processId = Environment.ProcessId,
fencingToken = lockHandle.FencingToken,
electedAt = DateTimeOffset.UtcNow
});
await client.SetKeyValue(
"leader/email-scheduler/identity",
identity,
expiryTime: 20000
);
}Other services can read this key to discover the current leader. Set a TTL slightly longer than the lease duration so the identity key expires shortly after the lock.
Update the identity key during each heartbeat cycle to keep the TTL fresh:
while (!leaderCts.Token.IsCancellationRequested)
{
await Task.Delay(5000, leaderCts.Token);
(bool ok, _) = await lockHandle.TryExtend(TimeSpan.FromSeconds(15));
if (!ok)
{
leaderCts.Cancel();
return;
}
// Refresh the identity TTL.
await client.ExtendKeyValue("leader/email-scheduler/identity", 20000);
}Leader election puts one process in charge. A task queue distributes work across many processes. Each worker claims a task, processes it, and marks it complete. No two workers process the same task.
Store tasks as key-value entries. Use a sequence for unique task IDs:
await client.CreateSequence("task-ids");
async Task<string> EnqueueTask(KahunaClient client, string payload)
{
long taskId = await client.NextSequenceValue("task-ids");
string key = $"tasks/{taskId}";
await client.SetKeyValue(key, JsonSerializer.Serialize(new
{
id = taskId,
payload,
status = "pending",
createdAt = DateTimeOffset.UtcNow
}));
return key;
}A worker claims a task by acquiring a lock on that task’s key. The lock lease acts as a processing timeout:
async Task<bool> TryProcessTask(KahunaClient client, string taskKey)
{
await using KahunaLock taskLock = await client.GetOrCreateLock(
resource: $"lock/{taskKey}",
expiry: TimeSpan.FromSeconds(60)
);
if (!taskLock.IsAcquired)
return false;
KahunaKeyValue task = await client.GetKeyValue(taskKey);
var taskData = JsonSerializer.Deserialize<TaskRecord>(task.ValueAsString ?? "{}");
if (taskData?.Status != "pending")
return false;
try
{
await ExecuteTask(taskData, taskLock.FencingToken);
await client.SetKeyValue(taskKey, JsonSerializer.Serialize(taskData with
{
Status = "completed",
CompletedAt = DateTimeOffset.UtcNow
}));
return true;
}
catch (Exception ex)
{
await client.SetKeyValue(taskKey, JsonSerializer.Serialize(taskData with
{
Status = "failed",
Error = ex.Message
}));
return false;
}
}If the worker crashes while processing, the lock lease expires after 60 seconds. Another worker can then claim the task. The task stays in “pending” status because the crashed worker never updated it.
Each worker scans for pending tasks and claims them:
async Task RunWorker(KahunaClient client, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
List<KahunaKeyValue> tasks = await client.GetByBucket(
"tasks/",
KeyValueDurability.Persistent
);
bool processed = false;
foreach (KahunaKeyValue task in tasks)
{
var data = JsonSerializer.Deserialize<TaskRecord>(task.ValueAsString ?? "{}");
if (data?.Status == "pending")
{
bool claimed = await TryProcessTask(client, task.Key);
if (claimed) processed = true;
}
}
if (!processed)
{
// No work available. Wait before scanning again.
await Task.Delay(2000, ct);
}
}
}Multiple workers can run this loop concurrently. The lock on each
task ensures that only one worker processes it. If two workers try to
claim the same task, one gets IsAcquired = false and moves
on.
For long tasks, extend the lock lease during processing (the same pattern as the leader heartbeat):
async Task ExecuteLongTask(KahunaLock taskLock, TaskRecord task, CancellationToken ct)
{
foreach (var step in task.Steps)
{
await ProcessStep(step, ct);
(bool ok, _) = await taskLock.TryExtend(TimeSpan.FromSeconds(60));
if (!ok)
{
Console.WriteLine("Lost task lock. Another worker may take over.");
return;
}
}
}DisposeAsync finalizer does not run because the
process is gone.No manual intervention is required. The lease is the recovery mechanism.
TryExtend returns false.CancellationTokenSource.The window of danger is the time between the lease expiry and the moment the old leader detects the failure. During this window, both the old and new leaders may act. Fencing tokens prevent the old leader from corrupting shared state.
This is the scenario where fencing is critical. Without fencing, both leaders write to the shared resource. With fencing, the shared resource rejects writes from the old leader’s stale token.
For this to work correctly, the task processing must be idempotent or the task must record progress. If the first worker completed half the work before crashing, the second worker must be able to resume or redo the work safely.
Short leases (5 to 10 seconds) provide fast failover but require frequent renewals. Each renewal is a network round trip to Kahuna. If the network is unreliable, short leases increase the risk of losing leadership during a transient failure.
Long leases (30 to 60 seconds) tolerate network blips but delay failover after a crash. A crashed leader blocks progress for the full lease duration.
Renew at one-third of the lease duration. This gives two full buffer attempts:
| Lease Duration | Renewal Interval | Buffer Before Expiry |
|---|---|---|
| 9 seconds | 3 seconds | 6 seconds (2 retries) |
| 15 seconds | 5 seconds | 10 seconds (2 retries) |
| 30 seconds | 10 seconds | 20 seconds (2 retries) |
When a follower tries to acquire the lock, the wait
parameter controls how long it blocks. A long wait (30 to 60 seconds)
keeps the follower ready to take over quickly. A short wait (5 to 10
seconds) with external polling gives you more control over shutdown
behavior.
The retry interval (the polling frequency during the
wait period) should be short enough to detect a released lock quickly
but long enough to avoid excessive load. Values of 200 to 500
milliseconds work well for most workloads.
A common architecture uses both patterns together: a single leader distributes work, and multiple workers process it.
async Task DoLeaderWork(long fencingToken, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
// The leader generates tasks.
List<string> pendingItems = await FetchNewItemsFromExternalSource(ct);
foreach (string item in pendingItems)
{
long taskId = await _client.NextSequenceValue("task-ids");
await _client.SetKeyValue($"tasks/{taskId}", JsonSerializer.Serialize(new
{
id = taskId,
payload = item,
status = "pending",
assignedBy = fencingToken
}));
}
await Task.Delay(5000, ct);
}
}The leader creates tasks. Workers (which do not need to win an election) claim and process tasks independently. If the leader crashes, a new leader takes over task creation. The workers are unaffected because they operate on the task queue, not on the leadership lock.
Leader election in Kahuna uses a distributed lock as the coordination primitive. The process that holds the lock is the leader. A background heartbeat task renews the lease to keep leadership alive. When the leader fails, the lease expires and a follower acquires the lock automatically.
Fencing tokens prevent stale leaders from corrupting shared state during the window between lease expiry and failure detection. Every downstream write should carry the fencing token and reject stale values.
The same lock primitive supports task queues: each task gets its own lock, workers claim tasks by acquiring the lock, and crashed workers release their tasks through lease expiry.
The next chapter covers idempotency and duplicate prevention: how to make operations safe to retry without side effects.
In a distributed system, retries are not optional. They are inevitable. A client sends a request to the server. The network drops the response. The client does not know whether the server processed the request or not. It retries.
If the operation was “set the status to completed,” the retry is harmless. The status was already “completed,” and setting it again changes nothing. But if the operation was “add 100 to the balance,” the retry doubles the effect. The balance increases by 200 instead of 100.
An operation is idempotent when executing it multiple times produces the same result as executing it once. This chapter shows how to build idempotent operations with Kahuna’s key-value primitives, conditional writes, fencing tokens, and sequence-based deduplication.
Consider a simple counter increment:
// INCORRECT: not idempotent
KahunaKeyValue current = await client.GetKeyValue("stats/page-views");
long count = long.Parse(current.ValueAsString ?? "0");
await client.SetKeyValue("stats/page-views", (count + 1).ToString());This code reads the counter, adds 1, and writes the new value. If the client retries (because the response to the write was lost), it reads the already-incremented counter, adds 1 again, and writes a doubly-incremented value.
The problem is not the retry itself. The problem is that the operation has no way to detect that it already succeeded.
Distributed systems describe three levels of delivery guarantee:
Most systems provide at-least-once delivery by default: the client retries until it receives a success response. To get effectively-exactly-once behavior, the operation must be idempotent. A retry of an idempotent operation produces no additional effect, so “at least once” becomes equivalent to “exactly once” in practice.
Some operations are idempotent by nature. No extra work is required:
SetKeyValue("config/mode", "maintenance") can be called any
number of times. The result is always the same: the key holds the value
“maintenance.”DeleteKeyValue("temp/session-abc") succeeds on the first
call and returns “not found” on subsequent calls. Either way, the key is
gone.SetKeyValue("users/alice", "...", flags: KeyValueFlags.SetIfNotExists)
creates the key once. Retries see the key already exists and return
Success = false. No duplicate is created.These operations are safe to retry without any guarding mechanism.
These operations are dangerous to retry:
Each of these needs an explicit mechanism to make it safe for retries.
Every key in Kahuna has a revision number. The revision starts at 0 and increases by 1 on every successful write. You can use this revision as a guard: “write this value, but only if the revision is still what I saw when I read it.”
This is the Compare-Revision-And-Swap (CRAS) operation:
// Step 1: Read the current value and its revision.
KahunaKeyValue current = await client.GetKeyValue("stats/page-views");
long count = long.Parse(current.ValueAsString ?? "0");
long revision = current.Revision;
// Step 2: Write the new value, but only if the revision has not changed.
KahunaKeyValue result = await client.TryCompareRevisionAndSetKeyValue(
"stats/page-views",
(count + 1).ToString(),
compareRevision: revision
);
if (result.Success)
Console.WriteLine("Counter incremented.");
else
Console.WriteLine("Conflict: another write changed the key. Retry from step 1.");How this prevents double-counting:
compareRevision: 5.compareRevision: 5 again.The retry does not corrupt the data. The client can re-read the key, see that the value is already “101,” and conclude that the original write succeeded.
CRAS protects against concurrent writes and duplicate retries from the same read. But if the client crashes after reading and before writing, it restarts with no memory of the previous read. The new read sees the current state, and the new write succeeds normally. This is correct behavior, not a bug, because the first attempt never wrote anything.
The risk arises when the operation has external side effects. If the client charged a credit card, crashed, and retried, the charge happened but the status update did not. CRAS does not help here because the external side effect is outside Kahuna’s control. For that, you need a request-level idempotency key (covered later in this chapter).
Compare-Value-And-Swap (CVAS) writes a new value only if the current value matches an expected value:
KahunaKeyValue result = await client.TryCompareValueAndSetKeyValue(
"orders/abc/status",
value: "shipped",
compareValue: "paid"
);
if (result.Success)
Console.WriteLine("Order marked as shipped.");
else
Console.WriteLine("Order is not in 'paid' status. No change made.");This is idempotent for state-machine transitions. If the order is already “shipped” (from a previous attempt), the compare against “paid” fails and the retry is a no-op.
CVAS is useful when:
The simplest idempotency guard stores a record of the completed operation. Before doing work, check whether the record exists. If it does, skip the work.
async Task<bool> ProcessOrderIdempotently(KahunaClient client, string orderId, OrderDetails details)
{
// Try to create a completion record. If it already exists, we already processed this order.
KahunaKeyValue guard = await client.SetKeyValue(
$"processed/{orderId}",
"done",
flags: KeyValueFlags.SetIfNotExists,
durability: KeyValueDurability.Persistent
);
if (!guard.Success)
{
Console.WriteLine($"Order {orderId} was already processed. Skipping.");
return false;
}
// First time: do the actual work.
await ChargePayment(details);
await UpdateInventory(details);
await SendConfirmationEmail(details);
return true;
}The SetIfNotExists flag ensures that only one call
succeeds. All retries see the existing key and skip the work.
There is a gap between the guard write and the actual work. If the process crashes after writing the guard but before finishing the work, the retried call sees the guard and skips the work. The result: the work is never completed.
To close this gap, use a two-phase approach:
async Task ProcessOrderSafely(KahunaClient client, string orderId, OrderDetails details)
{
string guardKey = $"processed/{orderId}";
KahunaKeyValue existing = await client.GetKeyValue(guardKey);
if (existing.ValueAsString == "completed")
return;
// Mark as in-progress (not yet completed).
await client.SetKeyValue(
guardKey,
"in-progress",
flags: KeyValueFlags.SetIfNotExists
);
// Do the work.
await ChargePayment(details);
await UpdateInventory(details);
// Mark as completed.
await client.TryCompareValueAndSetKeyValue(
guardKey,
value: "completed",
compareValue: "in-progress"
);
}On retry:
Chapter 4 and Chapter 8 showed how fencing tokens prevent stale lock holders from corrupting data. The same mechanism provides exactly-once writes.
The pattern:
async Task ProcessPaymentWithFencing(KahunaClient client, string paymentId)
{
await using KahunaLock lockHandle = await client.GetOrCreateLock(
$"payment/{paymentId}",
expiry: TimeSpan.FromSeconds(30),
wait: TimeSpan.FromSeconds(10),
retry: TimeSpan.FromMilliseconds(200)
);
if (!lockHandle.IsAcquired)
throw new TimeoutException("Could not acquire payment lock.");
long token = lockHandle.FencingToken;
// Read the current state.
KahunaKeyValue state = await client.GetKeyValue($"payment/{paymentId}/state");
if (state.ValueAsString == "completed")
return;
// Charge the payment (external side effect).
await ChargePaymentGateway(paymentId);
// Write the result with the fencing token.
// Only succeeds if no higher token has written to this key.
await client.SetKeyValue(
$"payment/{paymentId}/state",
$"completed|token={token}"
);
}If the client retries after a timeout:
Fencing tokens are especially valuable when the operation involves an external system (a payment gateway, an email service) that cannot be rolled back. The token guarantees that only the most recent lock holder’s writes persist.
Kahuna’s sequencer supports idempotency keys natively (Chapter 5). When you allocate a value with an idempotency key, retrying with the same key returns the same value:
long orderId = await client.NextSequenceValue(
"order-ids",
idempotencyKey: $"checkout-{requestId}"
);If the response is lost and the client retries with the same
requestId, Kahuna returns the same orderId. No
new value is consumed. This is server-side deduplication: Kahuna stores
a map of recent idempotency keys and their results.
The same applies to range reservations:
KahunaSequenceRange batch = await client.ReserveSequenceRange(
"event-offsets",
count: 50,
idempotencyKey: $"import-batch-{batchId}"
);A retry with the same key and count returns the identical range. A retry with the same key but a different count returns an error, because changing the parameters after the first call is ambiguous.
Idempotency entries are not stored forever. Two server settings control retention:
| Setting | Default | Description |
|---|---|---|
--sequencer-idempotency-retention-max |
256 | Maximum entries per sequence |
--sequencer-idempotency-retention-ttl |
600 seconds | Time window for replay |
After an entry is evicted (by count or age), a retry with that key allocates fresh values. Set these limits based on how long your retry windows last.
Inside an interactive transaction session (Chapter 6), each operation
carries a TransactionOperationId: a 128-bit random
identifier. The client generates one automatically for every
operation.
If a network timeout leaves the outcome of an operation unknown, the
client can resubmit the same operation with the same
TransactionOperationId. The server detects the duplicate
and replays the original result instead of executing the operation
again.
This deduplication is transparent. You do not need to manage
operation IDs yourself. The KahunaTransactionSession
generates a fresh random ID for every call you make. The server uses
these IDs internally to handle retransmissions during the session’s
lifetime.
The IDs also support derived sub-operations. When an operation spans multiple pages (as in a paginated scan), the client derives deterministic sub-IDs from the original. If a page request is retried, the derived ID is the same, and the server replays the result.
Real applications often chain multiple steps. Each step must be idempotent independently, because a failure can happen between any two steps.
Consider an order processing pipeline:
async Task ProcessOrder(KahunaClient client, string orderId, OrderDetails order)
{
// Step 1: Validate and reserve inventory.
await ReserveInventory(client, orderId, order);
// Step 2: Charge payment.
await ChargePayment(client, orderId, order);
// Step 3: Confirm order.
await ConfirmOrder(client, orderId, order);
}Each step uses a state key to track progress:
async Task ReserveInventory(KahunaClient client, string orderId, OrderDetails order)
{
string stepKey = $"orders/{orderId}/step/reserve";
KahunaKeyValue status = await client.GetKeyValue(stepKey);
if (status.ValueAsString == "done")
return;
// Do the reservation.
foreach (var item in order.Items)
{
await client.RetryableTransaction(
new KahunaTransactionOptions { Timeout = 5000 },
async (session, ct) =>
{
KahunaKeyValue stock = await session.GetKeyValue($"inventory/{item.Sku}");
int available = int.Parse(stock.ValueAsString ?? "0");
if (available < item.Quantity)
throw new InvalidOperationException($"Insufficient stock for {item.Sku}.");
await session.SetKeyValue(
$"inventory/{item.Sku}",
(available - item.Quantity).ToString()
);
await session.Commit(ct);
}
);
}
await client.SetKeyValue(stepKey, "done");
}
async Task ChargePayment(KahunaClient client, string orderId, OrderDetails order)
{
string stepKey = $"orders/{orderId}/step/charge";
KahunaKeyValue status = await client.GetKeyValue(stepKey);
if (status.ValueAsString == "done")
return;
// Acquire a lock with fencing to prevent duplicate charges.
await using KahunaLock chargeLock = await client.GetOrCreateLock(
$"charge/{orderId}",
expiry: TimeSpan.FromSeconds(30)
);
if (!chargeLock.IsAcquired)
throw new InvalidOperationException("Could not acquire charge lock.");
// Check again after acquiring the lock (another instance may have completed it).
status = await client.GetKeyValue(stepKey);
if (status.ValueAsString == "done")
return;
await CallPaymentGateway(order.PaymentDetails, chargeLock.FencingToken);
await client.SetKeyValue(stepKey, "done");
}
async Task ConfirmOrder(KahunaClient client, string orderId, OrderDetails order)
{
// Use CVAS: transition from "processing" to "confirmed."
await client.TryCompareValueAndSetKeyValue(
$"orders/{orderId}/status",
value: "confirmed",
compareValue: "processing"
);
}Each step checks a status key before doing work. If the step already completed, it returns immediately. If the process crashes and restarts, it resumes from the last incomplete step. No step executes twice.
| Mechanism | Best For | Scope |
|---|---|---|
| Unconditional SET | Values that can be overwritten safely | Single key |
| SetIfNotExists | One-time creation (guards, flags) | Single key |
| CRAS (Compare-Revision-And-Swap) | Read-modify-write on a single key | Single key |
| CVAS (Compare-Value-And-Swap) | State machine transitions | Single key |
| Fencing tokens | Protecting external side effects | Lock scope |
| Sequence idempotency keys | Preventing duplicate ID allocation | Single sequence |
| Transaction operation IDs | Retransmission within a session | Transaction scope |
| Step-based progress tracking | Multi-step pipelines | Workflow scope |
For simple key-value updates, CRAS or CVAS is enough. For operations with external side effects (payment charges, email sends), use fencing tokens. For multi-step workflows, combine step-based progress tracking with per-step idempotency guards.
// INCORRECT: race condition between check and write
KahunaKeyValue existing = await client.GetKeyValue($"processed/{requestId}");
if (existing.Success)
return;
await DoWork();
await client.SetKeyValue($"processed/{requestId}", "done");Two concurrent requests can both read “not found” and both proceed to
do the work. Use SetIfNotExists or CRAS to make the
check-and-write atomic.
// INCORRECT: client-side flag does not survive a restart
private bool _processed = false;
async Task Handle(string requestId)
{
if (_processed) return;
await DoWork();
_processed = true;
}Client-side state is lost on crash or restart. Store the idempotency record in Kahuna, where it survives process failures and is visible to all instances.
Writing a guard and then doing work is not atomic. If the process crashes between the two, the guard says “done” but the work did not complete. Use a two-phase status (“in-progress” then “completed”) so a retry can detect and resume incomplete work.
Retries are inevitable in distributed systems. Every mutating operation must be safe to retry, either naturally or through an explicit mechanism.
Kahuna provides several building blocks for idempotency. Conditional writes (CRAS, CVAS, SetIfNotExists) make single-key updates safe. Fencing tokens protect operations that touch external systems. Sequence idempotency keys prevent duplicate ID allocation at the server level. Transaction operation IDs handle retransmission within a session transparently.
For multi-step workflows, combine step-based progress tracking with per-step idempotency guards. Each step records its completion status. On retry, the pipeline resumes from the last incomplete step.
The next chapter covers globally unique identifier generation: patterns for producing unique, sortable IDs at scale using Kahuna’s distributed sequencer.
Most distributed applications need unique identifiers. Order numbers, event offsets, invoice IDs, user handles, log sequence numbers: each must be unique across every node, process, and data center that participates in the system.
On a single database, an auto-incrementing column solves the problem. The database serializes all inserts through one counter. In a distributed system, there is no single database. Two services inserting records into two different databases will produce colliding IDs unless they coordinate.
This chapter explores patterns for generating unique IDs with Kahuna’s distributed sequencer. It compares Kahuna sequences with UUIDs, Snowflake IDs, and database sequences, and shows how to tune the sequencer for different workloads.
A good identifier scheme must satisfy at least two requirements:
Beyond these, different applications value different properties:
No single scheme satisfies all of these. Each scheme makes trade-offs.
A UUID v4 is a 128-bit random number. It requires no coordination: any node can generate one independently.
string id = Guid.NewGuid().ToString();
// "3f2504e0-4f89-11d3-9a0c-0305e82c3301"Strengths:
Weaknesses:
UUID v7 embeds a Unix timestamp in the most significant bits. This makes UUIDs roughly sortable by creation time.
string id = Guid.CreateVersion7().ToString();This solves the sortability and B-tree fragmentation problems but does not improve human readability. The IDs are still 36 characters long.
Twitter’s Snowflake scheme packs a 64-bit integer from three components: a timestamp (41 bits), a machine ID (10 bits), and a per-machine sequence (12 bits). Each machine generates IDs independently.
Strengths:
bigint).Weaknesses:
A single database generates sequential IDs through an auto-incrementing column.
Strengths:
Weaknesses:
Kahuna’s distributed sequencer generates monotonically increasing 64-bit integers. Block-based allocation amortizes the cost of consensus across many values (Chapter 5).
Strengths:
Weaknesses:
| Property | UUID v4 | UUID v7 | Snowflake | DB Auto-Inc | Kahuna Sequence |
|---|---|---|---|---|---|
| Coordination required | No | No | Machine ID | Single DB | Kahuna cluster |
| Sortable by time | No | Yes | Yes | Yes | Yes |
| Human-readable | No | No | No | Yes | Yes |
| Contiguous | No | No | No | Yes | Configurable |
| Throughput | Unlimited | Unlimited | 4,096/ms/machine | DB-limited | Tunable (block size) |
| Size | 128 bits | 128 bits | 64 bits | 32 or 64 bits | 64 bits |
| Offline generation | Yes | Yes | Yes | No | No |
| B-tree friendly | No | Yes | Yes | Yes | Yes |
Choose Kahuna sequences when you need human-readable, sortable, unique IDs and you already run a Kahuna cluster. Choose UUIDs when you need offline generation or cannot depend on a central service. Choose Snowflake when you need high throughput without any coordination service and can manage machine IDs.
The simplest pattern: one sequence per type of entity.
await client.CreateSequence("order-ids", initialValue: 10000);
await client.CreateSequence("invoice-ids", initialValue: 100000);
await client.CreateSequence("user-ids");
// In the order service:
long orderId = await client.NextSequenceValue("order-ids");
// In the billing service:
long invoiceId = await client.NextSequenceValue("invoice-ids");
// In the user service:
long userId = await client.NextSequenceValue("user-ids");Each entity type gets its own counter. The counters are independent. The order service and the billing service can allocate IDs concurrently without contention because they use different sequences.
Setting initialValue to a non-zero number makes early
IDs more presentable. Order #10001 looks more professional than order
#1.
In a multi-tenant system, each tenant can have its own sequence:
async Task<long> GetNextOrderId(KahunaClient client, string tenantId)
{
string sequenceName = $"orders/{tenantId}";
try
{
return await client.NextSequenceValue(sequenceName);
}
catch (KahunaException ex) when (ex.Message.Contains("NotFound"))
{
await client.CreateSequence(sequenceName, initialValue: 1000);
return await client.NextSequenceValue(sequenceName);
}
}Tenant A’s order #1001 and tenant B’s order #1001 are independent. This is useful when tenants expect their own numbering (invoices, tickets, work orders).
The trade-off is the number of sequences. If you have 10,000 tenants,
you have 10,000 sequences. Each sequence occupies a small amount of
memory on the sequencer actor that manages it. The server setting
--sequencer-max-sequences-per-actor (default: 10,000)
controls how many sequences one actor keeps in memory. Least recently
used sequences are evicted and reloaded on demand.
Combine a sequence number with a prefix to create IDs that carry context:
long seq = await client.NextSequenceValue("invoice-ids");
string invoiceId = $"INV-{DateTime.UtcNow:yyyyMM}-{seq:D6}";
// "INV-202608-010042"The prefix makes the ID self-describing. A support engineer can see
at a glance that “INV-202608-010042” is an invoice from August 2026. The
sequence number provides uniqueness. The zero-padded format
(D6) makes IDs sort correctly as strings.
Be careful with this pattern. The date prefix is informational, not a uniqueness guarantee. The sequence number alone guarantees uniqueness. If you reset the sequence or create a new one each month, you must ensure old and new sequences do not overlap.
When you import data in bulk, allocating one ID at a time is inefficient. Reserve a range and assign IDs locally:
async Task ImportCustomers(KahunaClient client, List<CustomerRecord> records)
{
KahunaSequenceRange range = await client.ReserveSequenceRange(
"customer-ids",
count: records.Count,
idempotencyKey: $"import-{batchId}"
);
long id = range.Start;
foreach (CustomerRecord record in records)
{
record.Id = id++;
await SaveToDatabase(record);
}
}One network round trip reserves all the IDs. The application assigns them locally. The idempotency key ensures that retrying the import does not consume a second range.
Within a reserved range, IDs are contiguous. This is useful for data imports where contiguous numbering is a requirement.
When multiple services need non-overlapping ID ranges, use staggered sequences:
int shardCount = 4;
for (int i = 0; i < shardCount; i++)
{
await client.CreateSequence(
$"event-ids-shard-{i}",
initialValue: i,
increment: shardCount
);
}
// Shard 0 produces: 4, 8, 12, 16, ...
// Shard 1 produces: 5, 9, 13, 17, ...
// Shard 2 produces: 6, 10, 14, 18, ...
// Shard 3 produces: 7, 11, 15, 19, ...Each shard generates IDs independently. The increment ensures the
ranges never overlap. The shard number is encoded in the last bits of
every ID (id % shardCount == shardIndex).
This pattern is useful when each shard writes to its own database partition and you need globally unique IDs without cross-shard coordination at write time.
Some jurisdictions require contiguous invoice numbers with no gaps. Set the block size to 1:
kahuna-server --sequencer-block-size 1With a block size of 1, every allocation requires a Raft round trip. This is slower (roughly 1,000 times slower than the default block size of 1,000) but guarantees no gaps from unused block remainders.
Even with block size 1, an idempotent allocation with a lost response and no retry can appear as a gap to the application. The sequence advanced, but the application never used the value. To close this gap, use an idempotency key:
long invoiceNumber = await client.NextSequenceValue(
"invoice-numbers",
idempotencyKey: $"create-invoice-{requestId}"
);If the response is lost, the retry returns the same number. No value is wasted.
The --sequencer-block-size setting controls the
trade-off between throughput and gap size:
| Block Size | Raft Commits Per 10,000 IDs | Maximum Gap on Failure | Use Case |
|---|---|---|---|
| 1 | 10,000 | 0 | Gap-free numbering (invoices, legal documents) |
| 10 | 1,000 | 9 | Low-gap tolerance with moderate throughput |
| 100 | 100 | 99 | Balanced for most workloads |
| 1,000 (default) | 10 | 999 | High throughput (event offsets, log entries) |
| 10,000 | 1 | 9,999 | Maximum throughput (analytics, telemetry) |
The “maximum gap on failure” column shows the worst case: a node reserves a full block and crashes before issuing any value from it. In practice, gaps are smaller because the node usually issues some values before failing.
Each block has a lease (default: 5 seconds, configurable with
--sequencer-block-lease). If a block sits in memory longer
than its lease without being revalidated, the sequencer checks the
durable record before serving more values. This prevents a stale leader
from issuing values that conflict with a new leader’s allocations.
Shorter leases reduce stale-leader exposure but increase revalidation overhead. For most workloads, the 5-second default is appropriate.
This is the fundamental gap trade-off of block allocation. The gap is bounded by the block size. With the default block size of 1,000, the maximum gap from a single failure is 999 values.
If a sequence has a maximum value and the high-water mark reaches it, further allocations fail:
try
{
long id = await client.NextSequenceValue("limited-ids");
}
catch (KahunaException ex)
{
Console.WriteLine($"Sequence exhausted: {ex.Message}");
}Plan for exhaustion in advance. Monitor the current high-water mark
with GetSequence and alert when it approaches the
maximum:
KahunaSequence? seq = await client.GetSequence("limited-ids");
if (seq != null && seq.MaxValue > 0)
{
double usedPercent = (double)seq.CurrentValue / seq.MaxValue * 100;
if (usedPercent > 90)
Console.WriteLine($"WARNING: Sequence is {usedPercent:F1}% exhausted.");
}The CurrentValue property is the high-water mark (the
highest reserved value), not the last value issued. It may be higher
than the last value a client received.
During a Raft leadership change, the old leader’s in-memory block is discarded. The new leader starts fresh by reserving a new block. This causes a gap equal to the unused portion of the old block.
The gap preserves correctness: the new leader cannot reuse values from the old block because it does not know which values were already issued. Starting from the next block boundary guarantees uniqueness.
Kahuna sequences are a poor fit in some scenarios:
This example creates a service that generates formatted order IDs with a prefix, using batch allocation for throughput:
public class OrderIdGenerator
{
private readonly KahunaClient _client;
private readonly string _sequenceName = "order-ids";
private readonly int _batchSize = 100;
private long _nextId;
private long _maxId;
private readonly SemaphoreSlim _lock = new(1, 1);
public OrderIdGenerator(KahunaClient client)
{
_client = client;
}
public async Task Initialize()
{
try
{
await _client.CreateSequence(_sequenceName, initialValue: 100000);
}
catch (KahunaException)
{
// Sequence already exists.
}
}
public async Task<string> NextOrderId()
{
await _lock.WaitAsync();
try
{
if (_nextId >= _maxId)
{
KahunaSequenceRange range = await _client.ReserveSequenceRange(
_sequenceName,
count: _batchSize
);
_nextId = range.Start;
_maxId = range.End + 1;
}
long id = _nextId++;
return $"ORD-{id}";
}
finally
{
_lock.Release();
}
}
}Usage:
var generator = new OrderIdGenerator(client);
await generator.Initialize();
string id1 = await generator.NextOrderId(); // "ORD-100001"
string id2 = await generator.NextOrderId(); // "ORD-100002"The generator reserves 100 IDs at a time and serves them from memory. After 100 IDs, it reserves another batch. This reduces network round trips to one per 100 IDs.
The SemaphoreSlim protects the in-memory range from
concurrent access. If multiple threads call NextOrderId
simultaneously, only one reserves a new batch while the others wait.
If the process crashes with unused IDs in the batch, those IDs are lost (a gap of at most 99 values). For most order numbering, this is acceptable.
Kahuna’s distributed sequencer provides human-readable, sortable, unique IDs without a single-database bottleneck. Block allocation delivers high throughput by amortizing Raft consensus across many values.
Choose the block size based on your tolerance for gaps: block size 1 for gap-free numbering, larger blocks for higher throughput. Use range reservation for batch imports. Use staggered sequences for sharded ID spaces. Use idempotency keys to prevent duplicate allocation on retries.
For workloads that need offline generation, unpredictable IDs, or cross-cluster uniqueness, consider UUIDs or Snowflake IDs instead.
The next chapter covers service coordination: distributed configuration, service presence detection, and session management.
Backend services in a distributed system need shared state. A feature flag must be visible to every instance of the API. A scheduler must know which workers are alive. A game server must track which players are in a lobby. Each of these problems requires state that is consistent, fault-tolerant, and observable across multiple processes.
This chapter shows four coordination patterns built on Kahuna’s key-value store: distributed configuration, service presence detection, session management, and game lobby coordination.
Many applications store configuration in a file or environment variable. This works until you need to change a value without restarting the application. A distributed configuration registry stores configuration in Kahuna, where any service instance can read the current value and where updates take effect immediately.
Use a key prefix to organize configuration by domain:
await client.SetKeyValue("config/api/rate-limit", "1000");
await client.SetKeyValue("config/api/timeout-ms", "5000");
await client.SetKeyValue("config/features/dark-mode", "true");
await client.SetKeyValue("config/features/beta-signup", "false");Each configuration entry is a key-value pair. The prefix
(config/api/, config/features/) groups related
entries.
Read a single value:
KahunaKeyValue entry = await client.GetKeyValue("config/api/rate-limit");
int rateLimit = int.Parse(entry.ValueAsString ?? "500");Read all entries in a group:
List<KahunaKeyValue> apiConfig = await client.GetByBucket(
"config/api/",
KeyValueDurability.Persistent
);
foreach (KahunaKeyValue kv in apiConfig)
{
Console.WriteLine($"{kv.Key} = {kv.ValueAsString}");
}GetByBucket returns up to 4,096 entries that match the
prefix. For configuration registries, this limit is rarely a concern. If
you have more than 4,096 configuration entries, use
ScanAllByPrefix instead, which streams all matching
entries:
await foreach (KahunaKeyValue kv in client.ScanAllByPrefix("config/"))
{
Console.WriteLine($"{kv.Key} = {kv.ValueAsString}");
}When two operators update the same configuration key at the same time, one update can overwrite the other. Use Compare-Revision-And-Swap (CRAS) to prevent lost updates:
async Task<bool> UpdateConfig(KahunaClient client, string key, string newValue)
{
KahunaKeyValue current = await client.GetKeyValue(key);
KahunaKeyValue result = await client.TryCompareRevisionAndSetKeyValue(
key,
newValue,
compareRevision: current.Revision
);
return result.Success;
}If another update changed the key between the read and the write, the revision does not match and the write fails. The caller can retry with the fresh value.
Kahuna does not provide a push-based watch mechanism (unlike etcd’s watch API). Services must poll for changes. A simple polling loop reads configuration periodically and applies changes:
public class ConfigPoller
{
private readonly KahunaClient _client;
private readonly string _prefix;
private readonly Dictionary<string, (string Value, long Revision)> _cache = new();
public ConfigPoller(KahunaClient client, string prefix)
{
_client = client;
_prefix = prefix;
}
public async Task PollOnce()
{
List<KahunaKeyValue> entries = await _client.GetByBucket(
_prefix,
KeyValueDurability.Persistent
);
foreach (KahunaKeyValue entry in entries)
{
if (_cache.TryGetValue(entry.Key, out var cached) && cached.Revision == entry.Revision)
continue;
_cache[entry.Key] = (entry.ValueAsString ?? "", entry.Revision);
OnConfigChanged(entry.Key, entry.ValueAsString);
}
}
private void OnConfigChanged(string key, string? value)
{
Console.WriteLine($"Config changed: {key} = {value}");
}
}The poller compares revisions to detect changes. If the revision for a key has not changed since the last poll, the value is the same and no action is needed. This avoids unnecessary processing.
A poll interval of 5 to 30 seconds is appropriate for most configuration use cases. Critical settings (rate limits, circuit breaker thresholds) benefit from shorter intervals. Static settings (feature flags, display text) can tolerate longer intervals.
Some configuration entries should expire automatically. A maintenance window flag, for example, should turn itself off after a set duration:
await client.SetKeyValue(
"config/maintenance-mode",
"true",
expiryTime: 3600000 // 1 hour in milliseconds
);After one hour, the key expires. Services that poll for this key will see it disappear and exit maintenance mode. No manual cleanup is required.
In a microservice architecture, services need to know which instances of other services are alive. A load balancer needs a list of healthy backends. A job scheduler needs to know which workers are available.
Kahuna’s TTL-based keys provide a simple presence mechanism. Each service instance writes an ephemeral key with a short TTL. The key acts as a heartbeat. If the instance crashes, the key expires and the instance disappears from the registry.
Each service instance registers itself on startup and renews its registration periodically:
public class PresenceService : BackgroundService
{
private readonly KahunaClient _client;
private readonly string _serviceName;
private readonly string _instanceId;
private readonly int _ttlMs = 15000;
private readonly int _renewalMs = 5000;
public PresenceService(KahunaClient client, string serviceName)
{
_client = client;
_serviceName = serviceName;
_instanceId = Guid.NewGuid().ToString("N")[..8];
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
string key = $"presence/{_serviceName}/{_instanceId}";
string info = JsonSerializer.Serialize(new
{
instanceId = _instanceId,
host = Environment.MachineName,
port = 8080,
startedAt = DateTimeOffset.UtcNow
});
while (!stoppingToken.IsCancellationRequested)
{
await _client.SetKeyValue(key, info, expiryTime: _ttlMs);
await Task.Delay(_renewalMs, stoppingToken);
}
}
}The key includes the service name and a unique instance ID. The value contains connection details that other services need (host, port). The TTL is 15 seconds. The renewal runs every 5 seconds (one-third of the TTL), which gives two buffer attempts before the key expires.
Other services query the presence registry to find healthy instances:
async Task<List<ServiceInstance>> DiscoverInstances(KahunaClient client, string serviceName)
{
List<KahunaKeyValue> entries = await client.GetByBucket(
$"presence/{serviceName}/",
KeyValueDurability.Persistent
);
List<ServiceInstance> instances = new();
foreach (KahunaKeyValue entry in entries)
{
var instance = JsonSerializer.Deserialize<ServiceInstance>(entry.ValueAsString ?? "{}");
if (instance != null)
instances.Add(instance);
}
return instances;
}Only instances with unexpired keys appear in the result. A crashed instance’s key expires after 15 seconds. The discovery query returns only live instances.
For presence data that does not need Raft replication, use ephemeral durability:
await client.SetKeyValue(
$"presence/{serviceName}/{instanceId}",
info,
expiryTime: 15000,
durability: KeyValueDurability.Ephemeral
);Ephemeral keys are stored in memory on the receiving node. They are faster to write (no Raft round trip) but do not survive a node restart. If the Kahuna node holding the ephemeral key crashes, all presence records on that node vanish instantly.
Use ephemeral keys when:
Use persistent keys when:
When a service shuts down gracefully, it should delete its presence key immediately instead of waiting for the TTL:
public override async Task StopAsync(CancellationToken cancellationToken)
{
string key = $"presence/{_serviceName}/{_instanceId}";
await _client.DeleteKeyValue(key);
await base.StopAsync(cancellationToken);
}This makes the instance disappear from the registry immediately. Other services see the updated list on their next discovery query. Without graceful deregistration, the stale key persists for up to 15 seconds after the process exits.
Web applications and APIs often need server-side session state: a shopping cart, user preferences, or authentication tokens. Storing session state in Kahuna provides fault tolerance (the session survives a process restart) and consistency (multiple API instances can read the same session).
async Task<string> CreateSession(KahunaClient client, string userId)
{
string sessionId = Guid.NewGuid().ToString("N");
string key = $"sessions/{sessionId}";
string sessionData = JsonSerializer.Serialize(new
{
userId,
createdAt = DateTimeOffset.UtcNow,
cart = new List<string>()
});
await client.SetKeyValue(key, sessionData, expiryTime: 1800000); // 30 minutes
return sessionId;
}The session key includes a random ID. The TTL (30 minutes) defines the session timeout. If the user does not interact within 30 minutes, the session expires automatically.
async Task<SessionData?> GetSession(KahunaClient client, string sessionId)
{
KahunaKeyValue entry = await client.GetKeyValue($"sessions/{sessionId}");
if (!entry.Success)
return null;
// Extend the session TTL on each access (sliding expiration).
await client.ExtendKeyValue($"sessions/{sessionId}", 1800000);
return JsonSerializer.Deserialize<SessionData>(entry.ValueAsString ?? "{}");
}
async Task UpdateSession(KahunaClient client, string sessionId, SessionData data)
{
string key = $"sessions/{sessionId}";
KahunaKeyValue current = await client.GetKeyValue(key);
if (!current.Success)
throw new InvalidOperationException("Session not found.");
KahunaKeyValue result = await client.TryCompareRevisionAndSetKeyValue(
key,
JsonSerializer.Serialize(data),
compareRevision: current.Revision
);
if (!result.Success)
throw new InvalidOperationException("Session was modified concurrently. Retry.");
await client.ExtendKeyValue(key, 1800000);
}Key details:
ExtendKeyValue resets the TTL from the current time.
This provides sliding expiration: the session stays alive as long as the
user keeps interacting.TryCompareRevisionAndSetKeyValue prevents lost updates.
If two API instances update the same session simultaneously, one
succeeds and the other gets Success = false.Sessions expire automatically through TTL. No background cleanup is needed. When the TTL elapses, Kahuna removes the key.
For an explicit logout:
async Task DestroySession(KahunaClient client, string sessionId)
{
await client.DeleteKeyValue($"sessions/{sessionId}");
}To list all active sessions (for an admin dashboard, for example):
await foreach (KahunaKeyValue session in client.ScanAllByPrefix("sessions/"))
{
var data = JsonSerializer.Deserialize<SessionData>(session.ValueAsString ?? "{}");
Console.WriteLine($"Session: {session.Key}, User: {data?.UserId}");
}ScanAllByPrefix streams results with no upper limit, so
it handles large numbers of active sessions.
Online multiplayer games need coordination infrastructure: creating game rooms, matching players, tracking who is in a lobby, and managing game state transitions. Kahuna’s primitives (locks, TTL keys, prefix queries, transactions) combine to support these patterns.
Use a lock to ensure that room creation is exclusive:
async Task<string> CreateRoom(KahunaClient client, string gameMode, string hostPlayer)
{
long roomId = await client.NextSequenceValue("room-ids");
string roomKey = $"rooms/{roomId}";
string roomData = JsonSerializer.Serialize(new
{
id = roomId,
gameMode,
host = hostPlayer,
status = "waiting",
maxPlayers = 4,
createdAt = DateTimeOffset.UtcNow
});
await client.SetKeyValue(roomKey, roomData);
return roomKey;
}The sequencer generates a unique room ID. The room starts in the “waiting” status.
Each player registers their presence in the room with a TTL key:
async Task JoinRoom(KahunaClient client, string roomKey, string playerId)
{
string presenceKey = $"{roomKey}/players/{playerId}";
string playerInfo = JsonSerializer.Serialize(new
{
playerId,
joinedAt = DateTimeOffset.UtcNow,
ready = false
});
await client.SetKeyValue(presenceKey, playerInfo, expiryTime: 10000);
}
async Task SendHeartbeat(KahunaClient client, string roomKey, string playerId)
{
await client.ExtendKeyValue($"{roomKey}/players/{playerId}", 10000);
}The player’s presence key has a 10-second TTL. A heartbeat task renews it every 3 seconds. If the player disconnects (closes the browser, loses network), the key expires and the player disappears from the room.
async Task<List<PlayerInfo>> GetPlayersInRoom(KahunaClient client, string roomKey)
{
List<KahunaKeyValue> entries = await client.GetByBucket(
$"{roomKey}/players/",
KeyValueDurability.Persistent
);
return entries
.Select(e => JsonSerializer.Deserialize<PlayerInfo>(e.ValueAsString ?? "{}"))
.Where(p => p != null)
.ToList()!;
}Only players with unexpired keys appear. A player who disconnected 10 seconds ago is already gone from the list.
A game room moves through states: waiting, matching, playing, finished. Use CVAS (Compare-Value-And-Swap) to ensure state transitions are valid:
async Task<bool> StartGame(KahunaClient client, string roomKey)
{
// Only transition from "waiting" to "playing."
KahunaKeyValue room = await client.GetKeyValue(roomKey);
var roomData = JsonSerializer.Deserialize<RoomData>(room.ValueAsString ?? "{}");
if (roomData?.Status != "waiting")
return false;
var updated = roomData with { Status = "playing", StartedAt = DateTimeOffset.UtcNow };
KahunaKeyValue result = await client.TryCompareValueAndSetKeyValue(
roomKey,
JsonSerializer.Serialize(updated),
compareValue: room.ValueAsString!
);
return result.Success;
}If two players press “Start” at the same moment, only one CVAS
succeeds. The other gets Success = false. The room
transitions exactly once.
A simple matchmaking system scans for rooms in the “waiting” state and adds the player to the first room with available slots:
async Task<string?> FindMatch(KahunaClient client, string playerId, string gameMode)
{
List<KahunaKeyValue> rooms = await client.GetByBucket(
"rooms/",
KeyValueDurability.Persistent
);
foreach (KahunaKeyValue entry in rooms)
{
var room = JsonSerializer.Deserialize<RoomData>(entry.ValueAsString ?? "{}");
if (room?.Status != "waiting" || room.GameMode != gameMode)
continue;
List<PlayerInfo> players = await GetPlayersInRoom(client, entry.Key);
if (players.Count >= room.MaxPlayers)
continue;
// Try to join. The TTL-based presence handles the race:
// if two players join and exceed the limit, the host can
// enforce the cap before starting the game.
await JoinRoom(client, entry.Key, playerId);
return entry.Key;
}
return null;
}This is a simple linear scan. For production systems with thousands
of rooms, partition rooms by game mode (use a prefix like
rooms/{gameMode}/) and scan only the relevant
partition.
Finished game rooms can be deleted explicitly:
async Task CleanupRoom(KahunaClient client, string roomKey)
{
// Delete all player presence keys.
List<KahunaKeyValue> players = await client.GetByBucket(
$"{roomKey}/players/",
KeyValueDurability.Persistent
);
foreach (KahunaKeyValue player in players)
{
await client.DeleteKeyValue(player.Key);
}
// Delete the room itself.
await client.DeleteKeyValue(roomKey);
}Alternatively, create rooms with a TTL so they expire automatically after the maximum game duration:
await client.SetKeyValue(roomKey, roomData, expiryTime: 3600000); // 1 hourA service instance crashes. Its presence key has a 15-second TTL. After 15 seconds, the key expires. The next discovery query omits the crashed instance. Other instances continue to serve traffic. When the crashed instance restarts, it re-registers with a new presence key.
Two operators update the same configuration key at the same moment.
compareRevision: 5.
The write succeeds. The revision becomes 6.compareRevision: 5. The compare fails because the revision
is now 6.Operator B must re-read the key and decide whether to retry. No data is lost and no update is silently overwritten.
A player’s network connection drops. The player’s heartbeat stops. After 10 seconds, the presence key expires. The room’s player list no longer includes the disconnected player. The host (or the game logic) can detect the change on the next player list query and handle it: pause the game, assign the player’s slot to an AI, or end the match.
If the player reconnects within 10 seconds and resumes heartbeats, the key is refreshed and the player remains in the room.
| Concern | Persistent | Ephemeral |
|---|---|---|
| Raft consensus | Yes (durable, replicated) | No (in-memory, single node) |
| Write latency | Higher | Lower |
| Survives node restart | Yes | No |
| Use case | Configuration, sessions, game state | Presence heartbeats, rate counters |
| Consistency | Strong (linearizable) | Best-effort (single node) |
A good default: use persistent durability for data that matters (configuration, session state, room records) and ephemeral durability for data that is rebuilt quickly (presence heartbeats, temporary counters).
Kahuna’s key-value primitives support four common coordination
patterns. A configuration registry stores shared settings that all
service instances can read. Prefix queries (GetByBucket,
ScanAllByPrefix) retrieve groups of related entries. CRAS
prevents lost updates.
Presence detection uses TTL-based keys as heartbeats. A service instance writes a key with a short TTL and renews it periodically. When the instance crashes, the key expires and the instance disappears from the registry.
Session management stores user state in keyed entries with sliding
expiration. ExtendKeyValue resets the TTL on each access.
CRAS prevents concurrent session updates from overwriting each
other.
Game lobby coordination combines sequences (room IDs), TTL keys (player presence), prefix queries (player lists), and CVAS (state transitions) into a cohesive system.
The next chapter covers transactional workflows: how to decompose multi-step business operations into atomic transactions.
Chapter 6 introduced transactions: script transactions that execute atomically on the server, and interactive sessions that let the client make decisions between operations. Chapter 9 showed how to make individual operations idempotent.
This chapter brings both ideas together. Real business operations span multiple keys, multiple decisions, and sometimes multiple transactions. An order that reserves inventory, charges a payment, and sends a confirmation must either complete fully or leave no partial trace. This chapter shows how to design these workflows, where to draw transaction boundaries, and how to handle failures at each stage.
Consider this order placement code:
// INCORRECT: two separate writes, no transaction
await client.SetKeyValue("inventory/widget-a", (stock - 1).ToString());
await client.SetKeyValue("orders/123/status", "confirmed");If the process crashes between the two writes, the inventory is decremented but the order is never confirmed. The customer does not get their order. The inventory count is wrong.
Making both writes unconditional does not help either. If the second write fails (network timeout, server error), retrying the entire operation decrements inventory a second time.
The solution is a transaction. Both writes succeed together or fail together. No intermediate state is visible to other clients.
When the workflow logic is a simple sequence of reads and writes with straightforward conditions, a script transaction is the best fit:
string script = @"
LET stock = GET inventory/widget-a
IF stock = NOT FOUND THEN
THROW 'Item not found'
END
LET count = TO_INT(stock)
IF count < 1 THEN
THROW 'Out of stock'
END
SET inventory/widget-a TO_STRING(count - 1)
SET orders/@orderId/status 'confirmed'
SET orders/@orderId/item 'widget-a'
";
KahunaKeyValueTransactionResult result = await client.ExecuteKeyValueTransactionScript(
script,
parameters: new()
{
new() { Key = "@orderId", Value = orderId }
}
);The server executes the entire script as one atomic unit. If the stock check fails, no writes happen. If both writes succeed, they are visible together. No other client can see the inventory decremented without the order confirmed.
Script transactions are best when:
When the workflow requires application logic that the script language cannot express (calling an external API, running a complex computation, making a decision based on business rules), use an interactive session:
await client.RetryableTransaction(
new KahunaTransactionOptions
{
Timeout = 5000,
Locking = KeyValueTransactionLocking.Pessimistic
},
async (session, ct) =>
{
KahunaKeyValue stockEntry = await session.GetKeyValue("inventory/widget-a");
int stock = int.Parse(stockEntry.ValueAsString ?? "0");
if (stock < 1)
throw new InvalidOperationException("Out of stock.");
decimal price = await GetCurrentPrice("widget-a");
if (price > maxBudget)
throw new InvalidOperationException("Price exceeds budget.");
await session.SetKeyValue("inventory/widget-a", (stock - 1).ToString());
await session.SetKeyValue($"orders/{orderId}/status", "confirmed");
await session.SetKeyValue($"orders/{orderId}/price", price.ToString());
await session.Commit(ct);
}
);The GetCurrentPrice call is application logic that
cannot run inside a script. The interactive session lets you interleave
reads from Kahuna, external calls, and writes within one atomic
boundary.
RetryableTransaction wraps the session in a retry loop.
If a concurrent transaction modifies the same keys, the commit fails and
the entire callback runs again with a fresh session and fresh reads.
The locking mode determines how the transaction handles concurrent access.
new KahunaTransactionOptions
{
Locking = KeyValueTransactionLocking.Pessimistic
}Pessimistic locking acquires an exclusive lock on every key the session reads or writes. Other transactions that touch the same keys wait until this transaction commits or rolls back.
Use pessimistic locking when:
new KahunaTransactionOptions
{
Locking = KeyValueTransactionLocking.Optimistic
}Optimistic locking acquires locks only on writes. Reads are lock-free. Conflicts are detected at commit time: if a key that this transaction read was modified by another transaction, the commit is rejected.
Use optimistic locking when:
With optimistic locking, enable TrackAndValidate to
detect write-skew anomalies:
new KahunaTransactionOptions
{
Locking = KeyValueTransactionLocking.Optimistic,
ReadValidation = ReadValidation.TrackAndValidate
}The server records every key the session reads. At commit time, it checks whether any read key was modified after the read. If so, the commit is rejected. This prevents a class of bug where two transactions read overlapping data, make independent decisions, and both commit without conflict.
Not every workflow fits in a single transaction. A workflow that calls an external payment gateway cannot roll back the payment if a later step fails. The payment gateway does not participate in Kahuna’s transaction protocol.
For these workflows, decompose the work into multiple transactions with explicit progress tracking between them.
A saga is a sequence of transactions, each with a compensating action. If a step fails, the saga runs the compensating actions for all previously completed steps.
Here is an order placement saga with three steps:
async Task PlaceOrder(KahunaClient client, string orderId, OrderDetails order)
{
// Step 1: Reserve inventory (within a Kahuna transaction).
await ReserveInventory(client, orderId, order);
try
{
// Step 2: Charge payment (external API call, guarded by a lock).
await ChargePayment(client, orderId, order);
}
catch
{
// Compensate step 1: release the reserved inventory.
await ReleaseInventory(client, orderId, order);
throw;
}
// Step 3: Confirm the order (within a Kahuna transaction).
await ConfirmOrder(client, orderId);
}Each step is a separate transaction (or a lock-protected external call). If the payment fails, the compensation releases the inventory. The order is never left in an inconsistent state.
async Task ReserveInventory(KahunaClient client, string orderId, OrderDetails order)
{
await client.RetryableTransaction(
new KahunaTransactionOptions { Timeout = 5000 },
async (session, ct) =>
{
foreach (var item in order.Items)
{
KahunaKeyValue stock = await session.GetKeyValue($"inventory/{item.Sku}");
int available = int.Parse(stock.ValueAsString ?? "0");
if (available < item.Quantity)
throw new InvalidOperationException($"Insufficient stock for {item.Sku}.");
await session.SetKeyValue(
$"inventory/{item.Sku}",
(available - item.Quantity).ToString()
);
}
await session.SetKeyValue($"orders/{orderId}/step", "inventory-reserved");
await session.Commit(ct);
}
);
}async Task ChargePayment(KahunaClient client, string orderId, OrderDetails order)
{
await using KahunaLock chargeLock = await client.GetOrCreateLock(
$"charge/{orderId}",
expiry: TimeSpan.FromSeconds(30)
);
if (!chargeLock.IsAcquired)
throw new InvalidOperationException("Could not acquire charge lock.");
KahunaKeyValue stepCheck = await client.GetKeyValue($"orders/{orderId}/step");
if (stepCheck.ValueAsString == "payment-charged")
return;
await CallPaymentGateway(order.PaymentDetails, chargeLock.FencingToken);
await client.SetKeyValue($"orders/{orderId}/step", "payment-charged");
}The lock prevents duplicate charges on retry. The step check provides idempotency: if the step already completed, the method returns without charging again. The fencing token protects against stale lock holders (Chapter 9).
async Task ConfirmOrder(KahunaClient client, string orderId)
{
await client.RetryableTransaction(
new KahunaTransactionOptions { Timeout = 5000 },
async (session, ct) =>
{
await session.SetKeyValue($"orders/{orderId}/status", "confirmed");
await session.SetKeyValue($"orders/{orderId}/step", "completed");
await session.Commit(ct);
}
);
}async Task ReleaseInventory(KahunaClient client, string orderId, OrderDetails order)
{
await client.RetryableTransaction(
new KahunaTransactionOptions { Timeout = 5000 },
async (session, ct) =>
{
foreach (var item in order.Items)
{
KahunaKeyValue stock = await session.GetKeyValue($"inventory/{item.Sku}");
int current = int.Parse(stock.ValueAsString ?? "0");
await session.SetKeyValue(
$"inventory/{item.Sku}",
(current + item.Quantity).ToString()
);
}
await session.SetKeyValue($"orders/{orderId}/step", "cancelled");
await session.Commit(ct);
}
);
}Choosing where to draw transaction boundaries is a design decision. Two principles guide it.
A transaction holds locks (in pessimistic mode) or risks conflicts (in optimistic mode) for its entire duration. The longer the transaction, the more contention it creates.
Do not put external calls (HTTP requests, database queries, message publishes) inside a Kahuna transaction. These calls add latency and can time out, which extends the lock duration and increases the chance of a transaction timeout.
Instead, separate the external call from the transaction:
// Read data in a transaction.
string price;
await client.RetryableTransaction(
new KahunaTransactionOptions { Timeout = 5000 },
async (session, ct) =>
{
KahunaKeyValue entry = await session.GetKeyValue("products/widget-a");
price = entry.ValueAsString ?? "0";
await session.Commit(ct);
}
);
// External call outside the transaction.
bool approved = await PaymentGateway.Charge(price);
// Write the result in a second transaction.
if (approved)
{
await client.RetryableTransaction(
new KahunaTransactionOptions { Timeout = 5000 },
async (session, ct) =>
{
await session.SetKeyValue("orders/123/status", "paid");
await session.Commit(ct);
}
);
}Writes that must be consistent with each other belong in the same transaction. If decrementing inventory and recording the order must happen together, they go in one transaction. If they can tolerate temporary inconsistency (inventory decremented but order not yet recorded), they can go in separate transactions.
Ask this question: “If the process crashes between these two operations, is the data in a valid state?” If the answer is no, the operations belong in the same transaction.
When two transactions modify the same keys concurrently, one succeeds and the other is rejected.
The second transaction waits for the first to release its locks. If the wait exceeds the transaction timeout, the second transaction is aborted with a timeout error. The client can start a new session and retry.
Both transactions proceed without waiting. At commit time, the server
detects that one transaction read a key that the other modified. The
second transaction to commit is rejected with an Aborted
error. The client starts a new session and retries with fresh reads.
RetryableTransaction handles both cases automatically.
It catches Aborted, MustRetry, and
AlreadyLocked errors, waits with decorrelated jitter, and
retries with a new session:
await client.RetryableTransaction(
new KahunaTransactionOptions
{
Timeout = 5000,
Locking = KeyValueTransactionLocking.Optimistic,
ReadValidation = ReadValidation.TrackAndValidate
},
async (session, ct) =>
{
KahunaKeyValue a = await session.GetKeyValue("accounts/alice");
KahunaKeyValue b = await session.GetKeyValue("accounts/bob");
long balanceA = long.Parse(a.ValueAsString ?? "0");
long balanceB = long.Parse(b.ValueAsString ?? "0");
await session.SetKeyValue("accounts/alice", (balanceA - 50).ToString());
await session.SetKeyValue("accounts/bob", (balanceB + 50).ToString());
await session.Commit(ct);
}
);If another transaction modifies Alice’s or Bob’s balance between the reads and the commit, this transaction is rejected and retried. The retry reads fresh balances and recomputes the transfer.
The retry strategy uses up to 10 attempts with a jitter cap of 500
milliseconds. If all 10 attempts fail, the method throws a
KahunaException.
Some workflows fit entirely in a script with no external calls. Script transactions are faster (one round trip instead of many) and simpler (no session management, no retry loop).
string script = @"
LET total = 0
FOR key IN 'cart/user-42/' DO
LET item = GET key
IF item != NOT FOUND THEN
LET price = TO_INT(item)
LET total = total + price
END
END
IF total > 10000 THEN
SET orders/user-42/discount '10'
ELSE
SET orders/user-42/discount '0'
END
SET orders/user-42/total TO_STRING(total)
";
await client.ExecuteKeyValueTransactionScript(script);This script scans all items in a user’s cart, computes the total, applies a discount rule, and writes the result. The entire operation is atomic.
string script = @"
LET from_balance = TO_INT(GET accounts/@from)
LET to_balance = TO_INT(GET accounts/@to)
LET amount = TO_INT(@amount)
IF from_balance < amount THEN
THROW 'Insufficient funds'
END
SET accounts/@from TO_STRING(from_balance - amount)
SET accounts/@to TO_STRING(to_balance + amount)
";
KahunaTransactionScript transferScript = client.LoadTransactionScript(script);
await transferScript.Run(parameters: new()
{
new() { Key = "@from", Value = "alice" },
new() { Key = "@to", Value = "bob" },
new() { Key = "@amount", Value = "100" }
});The pre-hashed script caches the parsed plan on the server. Subsequent calls with different parameters skip parsing.
Without compensation, the inventory stays decremented and the order does not exist. The system is inconsistent.
The conflict is detected and resolved correctly. No stock is oversold.
What happens next depends on the transaction model:
In both models, the server drives the commit decision. A client crash after sending the commit request does not prevent the transaction from completing.
Before building a multi-step workflow, answer these questions:
Can the entire workflow fit in one transaction? If yes, use a single script or session transaction. This is simpler and avoids partial failure.
Does the workflow call external services? If yes, those calls cannot be inside a Kahuna transaction. Split the workflow into multiple transactions with progress tracking between them.
Does each step need a compensating action? If a later step fails, can you undo the earlier steps? Define compensation for each step.
Is each step idempotent? On retry, does the step produce the same result? Use step-tracking keys (Chapter 9) to guard against duplicate execution.
What locking mode fits? High contention favors pessimistic. Rare conflicts favor optimistic. When unsure, start with pessimistic and measure.
How long is the transaction? Keep each transaction under 5 seconds. If work takes longer, split it into shorter transactions.
Transactional workflows ensure that multi-key operations are atomic: all writes commit or none do. Script transactions handle workflows where all logic runs on the server. Interactive sessions handle workflows that need application logic between reads and writes.
For workflows that involve external services, decompose the work into multiple transactions with the saga pattern. Each step records its progress. Compensation actions undo completed steps if a later step fails.
Keep transactions short. Do not put external calls inside a
transaction. Group writes that must be consistent in the same
transaction. Use RetryableTransaction to handle conflicts
automatically.
The next chapter covers failure handling and retries: how to classify errors, configure timeouts, and build resilient applications.
Every distributed system fails. Networks drop packets. Nodes crash. Leaders change. Disks fill up. An application that ignores these failures will eventually lose data, hang indefinitely, or corrupt state.
This chapter catalogs every failure mode a Kahuna client can encounter. For each failure, it explains what happened, whether the operation is safe to retry, and what the application should do.
Kahuna operations return a response type that indicates the outcome.
When an operation fails, the client throws a
KahunaException. The exception carries an error code that
tells you what went wrong and whether you can retry.
The error codes fall into five categories:
MustRetry means the operation did not
execute and is safe to retry. Common causes:
The operation had no effect. Retry it immediately or after a short delay. The client’s connection layer will route the retry to the current leader.
Aborted means the operation executed
partially but was rejected at commit time. The most common cause is a
transaction conflict: another transaction modified a key that this
transaction read or wrote.
The transaction had no effect (all writes were rolled back). You can retry, but you must start a new transaction session and re-read all keys. Do not retry the commit on the same session.
AlreadyLocked means a key the
transaction tried to lock is held by another transaction. In pessimistic
mode, this happens when the lock wait exceeds the transaction timeout.
You can retry with a new session.
AdmissionRefused means the server
rejected the request because it is at its concurrency ceiling. The
server is not broken. It is protecting itself from overload.
Wait before retrying. Use exponential backoff with jitter. If
AdmissionRefused errors persist, the server is saturated
and needs more capacity or the workload needs throttling.
You can influence how long the client waits for an admission slot
with the AdmissionWaitMs transaction option. A non-zero
value tells the client to wait up to that many milliseconds for a slot
before failing.
Errored means the operation failed
permanently. Something is wrong at the server level (storage corruption,
internal bug, unrecoverable state). Retrying will produce the same
error.
Log the error and alert. Do not retry.
InvalidInput means the client sent a
malformed request: an empty key, an invalid flag combination, a script
syntax error. This is a bug in the application code. Fix the input.
DoesNotExist (for keys, locks, and
sequences) and NotSet (for conditional
writes that failed their condition) are not errors. They are normal
outcomes that indicate the resource does not exist or the condition was
not met. The application should handle these in its logic, not in its
error handler.
| Error Code | Meaning | Safe to Retry? | Action |
|---|---|---|---|
MustRetry |
Transient (leader change, stale route) | Yes, immediately | Retry the same operation |
Aborted |
Transaction conflict | Yes, with fresh state | Start a new session, re-read, retry |
AlreadyLocked |
Key locked by another transaction | Yes, with fresh state | Start a new session, retry |
AdmissionRefused |
Server overloaded | Yes, after backoff | Wait with exponential backoff, then retry |
Errored |
Permanent server error | No | Log and alert |
InvalidInput |
Malformed request | No | Fix the application code |
DoesNotExist |
Resource not found | N/A | Handle in application logic |
NotSet |
Conditional write failed | N/A | Handle in application logic |
For standalone key-value operations (not inside a transaction), implement a retry loop with exponential backoff and jitter:
async Task<KahunaKeyValue> SetWithRetry(
KahunaClient client, string key, string value, int maxRetries = 5)
{
int attempt = 0;
while (true)
{
try
{
return await client.SetKeyValue(key, value);
}
catch (KahunaException ex) when (
ex.KeyValueErrorCode == KeyValueResponseType.MustRetry ||
ex.KeyValueErrorCode == KeyValueResponseType.AdmissionRefused)
{
attempt++;
if (attempt >= maxRetries)
throw;
int delayMs = Math.Min(50 * (1 << attempt), 2000);
int jitter = Random.Shared.Next(0, delayMs / 2);
await Task.Delay(delayMs + jitter);
}
}
}The delay doubles on each attempt (50, 100, 200, 400, 800 ms) with a cap of 2 seconds. The jitter prevents multiple clients from retrying at the same instant.
For MustRetry, the delay can be shorter (the error is
transient and the next attempt often succeeds immediately). For
AdmissionRefused, a longer delay gives the server time to
recover.
For transactions, use the built-in RetryableTransaction
helper (Chapter 6). It handles retry logic automatically:
await client.RetryableTransaction(
new KahunaTransactionOptions
{
Timeout = 5000,
Locking = KeyValueTransactionLocking.Pessimistic
},
async (session, ct) =>
{
KahunaKeyValue counter = await session.GetKeyValue("stats/visits");
long count = long.Parse(counter.ValueAsString ?? "0");
await session.SetKeyValue("stats/visits", (count + 1).ToString());
await session.Commit(ct);
}
);RetryableTransaction retries on Aborted,
MustRetry, and AlreadyLocked. Its retry
strategy:
KahunaException
with Aborted.Any exception that is not Aborted,
MustRetry, or AlreadyLocked propagates
immediately. This includes InvalidInput,
Errored, application logic exceptions, and
cancellation.
Do not retry when:
InvalidInput. The request is malformed.
Retrying sends the same bad request.Errored. The server has a permanent
problem. Retrying will not fix it.Three layers of timeout apply to a Kahuna operation:
The DefaultOperationTimeout on the
KahunaOptions client configuration sets a deadline for
every operation that does not supply its own
CancellationToken. The default is 30 seconds.
var client = new KahunaClient("https://localhost:2070", new KahunaOptions
{
DefaultOperationTimeout = TimeSpan.FromSeconds(10)
});If the operation does not complete within this timeout, the client
throws an OperationCanceledException. The server may still
process the request. The client does not know whether the operation
succeeded.
The Timeout option on
KahunaTransactionOptions controls how long a transaction
session can live on the server:
new KahunaTransactionOptions { Timeout = 5000 } // 5 secondsWhen the timeout elapses, the server releases all locks held by the session and aborts any uncommitted transaction. This prevents a slow or crashed client from holding locks indefinitely.
Set the transaction timeout based on how long the transaction’s work takes. A 5-second timeout is appropriate for most read-modify-write patterns. Long-running transactions (batch imports, multi-step workflows) may need 10 to 30 seconds.
The wait parameter on GetOrCreateLock
controls how long the client retries before giving up:
await client.GetOrCreateLock(
"jobs/send-email",
expiry: TimeSpan.FromSeconds(15),
wait: TimeSpan.FromSeconds(10),
retry: TimeSpan.FromMilliseconds(200)
);If the lock is not acquired within 10 seconds,
IsAcquired is false. No exception is thrown.
| Timeout | Scope | Default | Set By |
|---|---|---|---|
| Operation timeout | Single RPC call | 30 seconds | KahunaOptions.DefaultOperationTimeout |
| Transaction timeout | Entire session | 5,000 ms (typical) | KahunaTransactionOptions.Timeout |
| Lock wait | Lock acquisition | 0 (no wait) | GetOrCreateLock wait parameter |
| Admission wait | Transaction admission | 0 (server default) | KahunaTransactionOptions.AdmissionWaitMs |
SetKeyValue request to node A (the
current leader for the target partition).MustRetry.The key point: MustRetry means the operation did not
execute. The retry is safe. No data was written by the failed
attempt.
MustRetry or
Aborted.When wrapped in RetryableTransaction, this happens
automatically: the callback is invoked again with a fresh session, and
all reads and writes are repeated from scratch.
OperationCanceledException or a gRPC
Unavailable error.Options for degradation:
If the client was constructed with multiple URLs, it distributes requests across available nodes automatically:
var client = new KahunaClient(new[]
{
"https://node1:2070",
"https://node2:2070",
"https://node3:2070"
});If a node is unreachable, the client skips it and sends the request to the next node in the round-robin rotation.
Aborted.RetryableTransaction. The jitter
spreads retries over time.High contention causes many retries. Two strategies reduce contention:
AdmissionRefused.To reduce AdmissionRefused errors:
AdmissionWaitMs to a non-zero value. The server
queues the request for up to that many milliseconds before rejecting
it.Commit request.This is the most difficult failure mode. The server may have committed the transaction. The client cannot know without checking.
What to do:
The server is the transaction coordinator. If the commit request reached the server and the server decided to commit, the writes are applied even if the client never receives the response. The data is consistent. The only problem is that the client does not know it.
When Kahuna is unreachable for an extended period, retrying every operation wastes resources and adds latency. A circuit breaker stops sending requests after a threshold of consecutive failures and periodically probes to detect recovery.
public class KahunaCircuitBreaker
{
private int _failureCount;
private DateTime _openUntil = DateTime.MinValue;
private readonly int _threshold;
private readonly TimeSpan _openDuration;
public KahunaCircuitBreaker(int threshold = 5, int openSeconds = 30)
{
_threshold = threshold;
_openDuration = TimeSpan.FromSeconds(openSeconds);
}
public bool IsOpen => DateTime.UtcNow < _openUntil;
public void RecordSuccess()
{
_failureCount = 0;
_openUntil = DateTime.MinValue;
}
public void RecordFailure()
{
_failureCount++;
if (_failureCount >= _threshold)
_openUntil = DateTime.UtcNow + _openDuration;
}
}Usage:
if (circuitBreaker.IsOpen)
{
// Fail fast. Do not send the request to Kahuna.
return GetCachedValue(key);
}
try
{
KahunaKeyValue result = await client.GetKeyValue(key);
circuitBreaker.RecordSuccess();
return result.ValueAsString;
}
catch (Exception)
{
circuitBreaker.RecordFailure();
return GetCachedValue(key);
}After 5 consecutive failures, the circuit opens for 30 seconds. During that window, requests bypass Kahuna and use a fallback. After 30 seconds, the next request probes Kahuna. If it succeeds, the circuit closes. If it fails, the circuit stays open for another 30 seconds.
KahunaException and check the error
code. Do not catch the base Exception type and
treat all errors the same.MustRetry immediately. The
operation did not execute. The next attempt will likely succeed.Aborted and AlreadyLocked
with a new session. Re-read all data. Do not reuse the old
session.AdmissionRefused. The
server needs time to catch up.Errored or
InvalidInput. These indicate a permanent
problem.RetryableTransaction for
transactions. It handles retry, backoff, and session
management.Kahuna errors fall into five categories: transient
(MustRetry), conflict (Aborted,
AlreadyLocked), overload (AdmissionRefused),
permanent (Errored), and client bugs
(InvalidInput). Each category demands a different
response.
For standalone operations, implement a retry loop with exponential
backoff and jitter. For transactions, use
RetryableTransaction, which retries up to 10 times with
decorrelated jitter backoff.
Configure timeouts at three levels: operation timeout (30 seconds default), transaction timeout (set per session), and lock wait (set per acquisition). A timeout with unknown outcome is the hardest failure mode: check the state or rely on idempotency guards.
A circuit breaker protects the application when Kahuna is unreachable for an extended period. It fails fast during the outage and probes periodically for recovery.
This chapter concludes Part II. The next chapter begins Part III (Understanding Kahuna Internals) with an architecture overview: the project layout, the component hierarchy, and how the three subsystems share a common structure.
Part II showed how to use Kahuna from the outside: keys, locks, sequences, transactions, and patterns for building distributed applications. Part III moves inside. The next ten chapters trace requests through the system, explain how data is partitioned and replicated, and describe how each subsystem works at the implementation level.
This chapter provides the map. It introduces the project layout, the component hierarchy, the shared patterns across subsystems, and the external libraries that Kahuna depends on. Every subsequent internals chapter zooms in on one part of this map.
The Kahuna repository contains several projects. Each has a distinct role:
| Project | Description |
|---|---|
Kahuna.Core |
The embeddable engine. Contains all subsystem managers (key-value, locks, sequencer), persistence backends, the actor-based processing pipeline, composition, and inter-node communication. |
Kahuna.Server |
The server executable (kahuna-server). Hosts gRPC and
REST endpoints, parses command-line options, and wires
Kahuna.Core into an ASP.NET host. |
Kahuna.Client |
The .NET client library. Provides KahunaClient, gRPC
and REST transport, request batching, and session management. |
Kahuna.Shared |
Types shared between client and server: enums
(KeyValueResponseType, LockResponseType,
SequenceResponseType), request and response DTOs, and
protocol types. |
Kahuna.Control |
The command-line client (kahuna-cli). A thin wrapper
over Kahuna.Client for interactive use. |
Test and benchmark projects (Kahuna.Client.Tests,
Kahuna.Server.Tests, Kahuna.Benchmark,
Kahuna.Microbenchmarks) round out the repository.
The two most important projects for understanding internals are
Kahuna.Core (where the engine lives) and
Kahuna.Server (where the engine is hosted).
Kahuna depends on two libraries that are central to its architecture:
Kommander is the Raft consensus library. It provides:
IRaft: the interface for proposing log
entries, querying leader status, and registering replication
callbacks.Kahuna does not implement consensus itself. Every durable write goes through Kommander’s Raft protocol. Kommander handles leader election, log replication, and commit notification. Kahuna registers callbacks to apply committed entries to its own state.
Nixie is the actor framework. It provides:
ActorSystem: the container that
manages actor lifecycles.IActor<TRequest, TResponse>: the
interface that all Kahuna actors implement. Each actor processes
messages one at a time in a single-threaded loop.IActorRef<TActor, TRequest>: a
handle for sending messages to an actor.Actors are Kahuna’s concurrency model. Each key-value partition, lock partition, and sequencer partition has its own set of actors. Because each actor processes one message at a time, there is no need for locks on the in-memory state within an actor. Concurrency comes from having many actors, not from shared-memory parallelism within one.
The IKahuna interface is the contract between the server
layer and the core engine. It declares every operation that Kahuna
supports, organized into categories:
Every routed operation has two variants. The LocateAnd*
variant (for example, LocateAndTrySetKeyValue) resolves
which partition owns the key, checks whether this node is the leader for
that partition, and either dispatches locally or forwards to the correct
node. The direct Try* variant (for example,
TrySetKeyValue) operates locally when the caller already
knows that this node is the correct leader.
KahunaManager is the class that implements
IKahuna. It is the root of the component hierarchy. Its
constructor calls KahunaNodeComposer.Build(), which
assembles all internal components in a specific order:
UnflushedOverlayPersistenceBackend. This overlay caches
writes that are committed in Raft but not yet flushed to disk, so reads
do not miss recently committed values.FairReadScheduler) for backend
reads and writes.SnapshotFloorStore,
CompletionReceiptStore,
TransactionRecordStore,
PreparedIntentStore.BackgroundWriterActor. This Nixie actor
flushes committed Raft entries to the persistence backend
asynchronously.LockManager.KeyValuesManager.SequencerManager. It depends on
KeyValuesManager because sequences store their durable
state as key-value entries.The result is a tree of components:
KahunaManager
├── KeyValuesManager
│ ├── KeyValueLocator (routes keys to partitions)
│ ├── KeyValueActor (per-partition, handles reads)
│ ├── PartitionWriteAggregatorActor (batches writes)
│ ├── DurableProposalSubmission (submits Raft proposals)
│ └── KeyValueReplicator (applies committed entries)
├── LockManager
│ ├── LockLocator (routes lock resources to partitions)
│ ├── LockActor (per-partition lock state)
│ ├── LockProposalActor (submits Raft proposals)
│ └── LockReplicator (applies committed entries)
├── SequencerManager
│ ├── SequenceLocator (routes sequence names to partitions)
│ └── SequenceActor (per-partition sequence state)
├── BackgroundWriterActor (async persistence)
├── PartitionPlacementCoordinator
└── IPersistenceBackend (RocksDB, SQLite, or Memory)
All three subsystems follow the same layered architecture. Understanding this pattern once makes every subsystem easier to follow.
The manager is the entry point. KeyValuesManager,
LockManager, and SequencerManager each own
their locator, actors, and configuration. The KahunaManager
delegates IKahuna method calls to the appropriate subsystem
manager.
Manager classes are split into partial classes by responsibility.
KeyValuesManager, for example, has separate files for admin
operations, routed operations (the LocateAnd* variants),
and local operations (the direct Try* variants).
The locator resolves a key (or lock resource, or sequence name) to
the correct partition and then to the current leader for that partition.
It uses the DataPartitionRouter, which maps key hashes to
partition numbers and partition numbers to leader node addresses.
If this node is the leader for the target partition, the locator
dispatches the request locally to the actor. If another node is the
leader, the locator forwards the request over
IInterNodeCommunication.
The actor holds the in-memory state for a partition.
KeyValueActor holds the key-value entries for its
partition. LockActor holds the lock states (owner, expiry,
fencing token). SequenceActor holds the sequence states
(high-water mark, block cache, idempotency entries).
Each actor implements IActor<TRequest, TResponse>
from Nixie. It processes one message at a time. This single-threaded
guarantee means the actor’s state does not need locks or concurrent data
structures. Within a partition, the actor is consistent by
construction.
Multiple actors can exist per partition (determined by a consistent-hash ring), allowing parallelism within a partition for non-overlapping keys.
When an actor needs to make a durable write, it submits a Raft proposal. The mechanism differs slightly by subsystem:
LockProposalActor that
submits one proposal per lock mutation. This is straightforward because
lock operations are individually small.PartitionWriteAggregatorActor that batches multiple writes
into a single Raft proposal. This is important for throughput: one Raft
round trip commits many key-value writes.After Raft commits a log entry, each subsystem has a replicator
(KeyValueReplicator, LockReplicator) that
applies the committed entry back to the actor’s in-memory state. This is
how followers stay in sync: they receive committed entries from the
leader and apply them through the same replicator path.
When a request arrives at a node that is not the leader for the target partition, the locator forwards it to the correct node. Two implementations exist:
GrpcInterNodeCommunication: the
production implementation. Nodes communicate over gRPC. This is the same
transport the client uses, but the inter-node variant carries internal
metadata (partition ID, leader hint) that the client does not send.MemoryInterNodeCommunication: the test
implementation. All nodes run in the same process. Messages are passed
through in-memory queues. This allows integration tests to simulate a
multi-node cluster without network overhead.The interface (IInterNodeCommunication) abstracts the
transport. The subsystem code does not know or care whether it is
running in a production cluster or a test harness.
Kahuna supports three persistence backends, all implementing
IPersistenceBackend:
| Backend | Use Case |
|---|---|
RocksDbPersistenceBackend |
Production default. LSM-tree storage with high write throughput. |
SqlitePersistenceBackend |
Alternative for smaller deployments. Single-file database. |
MemoryPersistenceBackend |
Testing. All data in memory. Lost on restart. |
An UnflushedOverlayPersistenceBackend wraps the chosen
backend. This overlay solves a timing problem: a Raft entry is committed
(replicated to a majority) before the BackgroundWriterActor
flushes it to the persistence backend. Without the overlay, a read
immediately after a committed write could miss the value because it is
not yet on disk. The overlay caches committed-but-unflushed writes and
serves them on reads.
The BackgroundWriterActor runs as a Nixie actor. It
receives batches of committed entries and writes them to the persistence
backend asynchronously. This decouples the Raft commit path (fast,
in-memory) from the disk I/O path (slower, batched).
Partition 0 is the meta partition. It stores cluster-wide metadata that all nodes need:
The meta partition uses the same Raft group as any data partition: it has a leader, replicates to a majority, and supports state transfer for catch-up. Its special status comes from its content, not from a different mechanism.
The client communicates with the server through two protocols:
The KahunaClient distributes requests across server URLs
in round-robin order. When constructed with multiple URLs, it rotates
through them. If a node is unreachable, the client skips it on the next
rotation.
The client also batches requests. The GrpcBatcher
coalesces multiple operations headed for the same server into a single
gRPC call. The BatchCoalescingThreshold and
BatchCoalescingDelayMs options on
KahunaOptions control when the batcher dispatches.
Kahuna’s architecture is a layered system of managers, locators, and actors, built on Kommander for consensus and Nixie for concurrency.
Every request follows the same path: the client sends it to a server
node. The server’s KahunaManager delegates to the
appropriate subsystem manager. The manager’s locator resolves the key to
a partition and a leader. If this node is the leader, the request is
dispatched to the partition’s actor. If not, it is forwarded to the
correct node. Durable writes go through Raft proposals. Committed
entries are applied by replicators and flushed to disk by the background
writer.
The three subsystems (key-value, locks, sequences) share this pattern but differ in their write pipelines: key-values batch writes for throughput, locks submit individual proposals, and sequences piggyback on the key-value pipeline.
The next chapter traces a single request through every layer of this
architecture, from the client’s SetKeyValue call to the
persistence backend and back.
Chapter 14 introduced Kahuna’s architecture: the project layout, the
component hierarchy, and the shared Manager, Locator, Actor pattern
across subsystems. This chapter traces a single request through every
layer of that architecture, from the client’s SetKeyValue
call to the persistence backend and back.
The goal is to build a complete picture of where each component sits in the request path. Every subsequent internals chapter zooms in on one part of this path. This chapter is the map that connects them all.
A SetKeyValue call passes through seven stages before
the value is durable: client batching, server dispatch, partition
routing, actor processing, Raft proposal, replication, and persistence.
Each stage is a distinct component with a distinct responsibility.
The application calls KahunaClient.SetKeyValue. The
client selects a server URL from its list in round-robin order. It
creates a GrpcTrySetKeyValueRequest and passes it to the
GrpcBatcher for the selected URL.
The GrpcBatcher does not send each request individually.
It uses gRPC bidirectional streaming to multiplex many operations over a
single long-lived HTTP/2 stream. When a request arrives, the batcher
enqueues it in a ConcurrentQueue and creates a
TaskCompletionSource that the caller awaits. A single
dispatch loop drains the queue and sends all pending items over the
shared stream.
Two settings control coalescing behavior:
BatchCoalescingThreshold (default 10): when fewer than
this many requests are ready, the batcher waits briefly before
sending.BatchCoalescingDelayMs (default 2): the maximum wait in
milliseconds, randomized to avoid synchronization.This batching is transparent to the caller. The caller awaits a task. The batcher groups that request with other concurrent requests, sends them over the stream, and completes the task when the server responds.
If a transport-level error occurs (gRPC connection drops), the
batcher invalidates the shared connection and retries once. The
GrpcCommunication layer above the batcher adds its own
retry logic: up to 5 retries on MustRetry responses and 2
retries on transport failures.
The request arrives at the server’s gRPC service.
KeyValuesService (which extends the generated
KeyValuer.KeyValuerBase) receives the call in its
TrySetKeyValue method. The method validates the input
(non-empty key, non-negative expiry), extracts the key, value, flags,
and durability from the protobuf request, and calls
keyValues.LocateAndTrySetKeyValue on the
IKahuna facade (implemented by
KahunaManager).
KahunaManager delegates to
KeyValuesManager, which delegates to
KeyValueLocator.LocateAndTrySetKeyValue. The delegation
chain is short: facade to subsystem manager to locator.
KeyValueLocator.LocateAndTrySetKeyValue is the routing
core. It determines which partition owns the key and whether this node
is the leader for that partition.
The method starts with input validation. If the key is empty or the
TTL is negative, it returns InvalidInput immediately. Then
it resolves the partition:
(partitionId, generation, isKeyRange, descriptor) = LocateRangeWithMode(key)
LocateRangeWithMode uses
RangeRouting.Locate, which hashes the key through the
KeySpaceRegistry and the DataPartitionRouter
to find the partition number. For key-range spaces (range-partitioned
keys), it also checks whether the range is quiesced (in the middle of a
split or merge). A quiesced range rejects writes with
MustRetry.
After resolving the partition, the locator checks whether this node is the leader:
if (await raft.AmILeaderIfHosted(partitionId, cancellationToken))
{
return await manager.TrySetKeyValue(...);
}If this node is the leader, the request is dispatched locally. If not, the locator resolves the leader address:
string? leader = await TryWaitForLeader(partitionId, cancellationToken);TryWaitForLeader calls
raft.TryResolveLeader. If no leader is found (the election
is still in progress, the partition is not hosted, or a
RaftException occurs), the method returns null. The locator
then returns MustRetry to the client.
If the leader is found and it is a remote node, the locator forwards
the request over IInterNodeCommunication:
response = await interNodeCommunication.TrySetKeyValue(leader, ...);The forwarded request carries the same parameters as the original,
plus a routedGeneration that tracks the range map version
at the coordinating node. The remote node uses this generation to detect
stale routes.
When the request is dispatched locally,
KeyValuesManager.TrySetKeyValue sends it to the
KeyValueActor for the target partition through a
consistent-hash router. The router selects one of several actors within
the partition based on the key hash.
The actor processes one message at a time. It checks conditions:
compare-and-swap revision, compare-and-swap value, flags
(SetIfNotExists, SetIfExists), and MVCC write
intents from concurrent transactions. If the conditions pass, the actor
prepares the write.
For persistent (durable) writes, the actor creates a Raft proposal.
It serializes the key, value, revision, timestamps, and flags into a
RaftProposalEntry and submits it to the write pipeline.
The proposal enters the PartitionWriteAggregatorActor.
This is where key-value writes differ from lock writes. Lock writes
submit one Raft proposal per operation. Key-value writes are batched:
the aggregator collects multiple proposals for the same partition and
submits them as a single Raft log entry.
The aggregator is a Nixie actor with per-partition state. When a proposal arrives, the aggregator enqueues it in the partition’s buffer. Two triggers cause the buffer to flush:
MaxBatchItems or
MaxBatchBytes.When the buffer flushes, the aggregator selects a batch and dispatches it. Before dispatch, each item is checked for staleness: if the range map moved since the item was admitted, or if the item exceeded its maximum queue age, it is released with a retryable failure.
Valid items are flattened into a single
RaftProposalEntry[] array. The aggregator calls
executor.ReplicateAsync(partitionId, entries, cancellationToken),
which submits the batch to Raft as one proposal.
Raft replicates the log entry to a majority of nodes. Each node
appends the entry to its write-ahead log. When a majority acknowledges
the append, the entry is committed. The
RaftBatchReplicationResult reports whether each entry
committed or failed.
The aggregator maps the result back to individual submissions. Each
KeyValueProposalRequest carries a reference to the
originating actor and its reply promise. When Raft commits, the
aggregator calls Complete on each request, which sends a
CompleteProposal message back to the actor. The actor
clears the replication intent, applies the committed state to the entry,
and resolves the response promise. On failure, the actor returns
MustRetry or Aborted.
Only one batch per partition is in flight at any time. While a batch is in Raft, new proposals accumulate in the buffer behind it. When the in-flight batch completes, the aggregator immediately dispatches the buffered items as the next batch.
After Raft commits the log entry, the KeyValueReplicator
applies it to the node’s state. The replicator runs on every node: the
leader and all followers.
The replicator deserializes the committed log entry with
ReplicationSerializer.UnserializeKeyValueMessage. Based on
the operation type (TrySet, TryDelete,
TryExtend), it performs three actions in order:
Record in the unflushed overlay.
UnflushedKeyValueWritesIndex.Record caches the committed
write so that reads can see it before the background writer flushes it
to disk.
Enqueue for persistence. The replicator sends a
QueueStoreKeyValue message to the
BackgroundWriterActor. This enqueues the write for
asynchronous disk flush.
Update the in-memory actor state. The replicator
sends an InvalidateOrApply message to the
KeyValueActor through the persistent router. The actor
either applies the new value (on followers, where the actor did not
process the original request) or invalidates its cached entry (on the
leader, where the actor already has the value from processing the
request).
The replicator also records a completion receipt. This receipt lets
the server answer re-commits for the same transaction with
Committed instead of MustRetry, even after the
write intent and MVCC snapshot are gone.
The BackgroundWriterActor is a Nixie actor that runs a
periodic flush timer. The default interval is configurable (5 seconds by
default). On each tick, it drains the dirty queue and writes batches to
the IPersistenceBackend (RocksDB, SQLite, or Memory).
Writes are batched up to 1,024 items or 512 KB per batch. The actor processes lock flushes and key-value flushes separately. After each flush, it advances durability floors, runs checkpoint operations (so Raft can compact its write-ahead log), and cleans up old revisions.
If a write fails, the actor retries up to 5 times with backoff. Items that fail all retries stay in the queue for the next flush cycle.
The separation between Raft commit and disk persistence is a key design choice. A Raft commit means the value is replicated to a majority of nodes in memory and in the write-ahead log. The background flush is an optimization: it moves data from the WAL to the structured storage backend. If a node crashes before the flush, it recovers from the WAL on restart.
After the write commits in Raft, the response travels back through the layers:
CompleteProposal message
to the KeyValueActor. The actor clears the replication
intent, applies the committed state, and resolves the
TaskCompletionSource that the gRPC handler is
awaiting.GrpcBatcher on the client side receives the
response and resolves the matching
TaskCompletionSource.KahunaClient.SetKeyValue wraps the result in a
KahunaKeyValue object and returns it to the
application.If the request was forwarded to a remote node, the inter-node communication layer receives the response from the remote leader and passes it back to the locator on the coordinating node.
The read path is shorter than the write path. Reads do not create Raft proposals and do not go through the write aggregator. The critical difference is how the read path ensures consistency.
The client calls KahunaClient.GetKeyValue. As with
writes, the request goes through the GrpcBatcher and
arrives at the server’s gRPC service. The service calls
keyValues.LocateAndTryGetValue, which delegates through
KahunaManager and KeyValuesManager to
KeyValueLocator.LocateAndTryGetValue.
The locator resolves the partition with RouteKey(key)
(the same hash-based routing as writes).
Here the read path diverges from the write path. For writes, checking
AmILeaderIfHosted is sufficient: if the leader belief is
wrong, the Raft proposal will fail. Writes are self-validating because
replication itself fails on a deposed leader.
Reads are different. A node that believes it is the leader but is actually partitioned from the majority will serve stale data as a successful response. There is no replication step to catch the stale belief.
To prevent stale reads, the locator calls
ConfirmLeadershipForRead:
if (await ConfirmLeadershipForRead(partitionId, cancellationToken))
return await manager.TryGetValue(...);ConfirmLeadershipForRead delegates to
raft.ConfirmLeadershipIfHosted, which performs a Raft
read-index check. This check confirms that the node is still the leader
by contacting a quorum. If the quorum confirms, the read proceeds. If
not, the locator returns MustRetry.
This is the mechanism that provides linearizable reads. Every read confirms leadership before serving data, so a read always reflects all writes that committed before it started.
If leadership is confirmed, the locator calls
manager.TryGetValue, which routes the request to the
KeyValueActor through the consistent-hash router. The actor
looks up the key in its in-memory state and returns the value, revision,
and timestamps.
If the key is not in the actor’s memory (it was evicted or never
loaded), the actor reads from the persistence backend. The
UnflushedOverlayPersistenceBackend wraps the raw backend
and intercepts reads. If the key is in the unflushed overlay (committed
in Raft but not yet flushed to disk), the overlay returns the cached
value. If not, the read falls through to the underlying RocksDB, SQLite,
or Memory backend.
If this node is not the leader, the locator calls
TryWaitForLeader to find the leader’s address. If the
leader is found, the request is forwarded:
response = await interNodeCommunication.TryGetValue(leader, ...);The remote leader runs the same leadership confirmation and local read. The response travels back through the inter-node communication layer to the coordinating node, and from there back to the client.
The two paths share the same routing and dispatch layers. They diverge at two points:
| Aspect | Write Path | Read Path |
|---|---|---|
| Leadership check | AmILeaderIfHosted (belief-based) |
ConfirmLeadershipForRead (quorum-confirmed) |
| Raft involvement | Submits a proposal, waits for majority commit | No proposal (read-only, confirmed via read-index) |
| Write aggregator | Batches proposals per partition | Not involved |
| Replicator | Applies committed entries on all nodes | Not involved |
| Persistence | Background flush after commit | Reads from overlay or backend |
Writes are self-validating: a proposal on a deposed leader fails at the replication step, so a belief-based check is sufficient. Reads need the quorum confirmation because there is no replication step to catch a stale leader.
The request lifecycle has two asynchronous boundaries where the request “detaches” from the caller and continues independently:
Raft replication. The write aggregator submits a
Raft proposal and awaits the result. The caller’s thread is free while
Raft replicates the entry to followers. The
TaskCompletionSource on the
DurableProposalSubmission completes when Raft
commits.
Background persistence. After Raft commits, the
KeyValueReplicator enqueues the write for disk flush but
does not wait for it. The response returns to the client before the
value is on disk. The unflushed overlay ensures reads see the committed
value in the interim.
Everything else is synchronous from the caller’s perspective: the client awaits the response, the server processes the request inline, and the locator routes it within the same async call chain.
Each stage adds latency. In a healthy cluster, the dominant cost is Raft replication (network round trips to followers).
| Stage | Typical Contribution |
|---|---|
| Client batching | 0 to 2 ms (coalescing delay) |
| Network to server | Network dependent |
| Server dispatch and routing | Microseconds |
| Actor processing | Microseconds |
| Write aggregation linger | 0 to linger setting (default: configurable) |
| Raft replication | 1 network round trip to majority |
| Response to client | Network dependent |
For reads, the Raft replication step is replaced by the read-index confirmation, which also requires a quorum round trip. Reads that hit the in-memory actor cache avoid disk I/O. Reads that miss the cache pay the additional cost of a backend lookup.
Inter-node forwarding adds one extra network hop. A request that arrives at the wrong node travels: client to coordinating node, coordinating node to leader, leader back to coordinating node, coordinating node back to client. This is why the client distributes requests across all nodes: most requests hit the correct leader on the first hop.
The locator calls TryWaitForLeader and receives null.
The node may not host this partition, or an election may be in progress.
The locator returns MustRetry. The client retries on
another node.
No data was read or written. The retry is safe.
The request arrives at the leader. A new election starts. The Raft
proposal fails because the node is no longer the leader. The write
aggregator releases the proposal with a transient failure. The actor
returns MustRetry.
On the write path, the proposal did not commit. No data was written.
On the read path, the leadership confirmation fails, and the locator
returns MustRetry before any data is read.
Under heavy write load, the BackgroundWriterActor may
fall behind. Committed values accumulate in the unflushed overlay. Reads
still succeed because the overlay serves committed-but-unflushed values.
The write-ahead log grows until the background writer catches up.
If the dirty queue is not empty after a flush cycle, the background writer sends itself another flush message immediately, without waiting for the next periodic timer tick. This self-scheduling drains the backlog as fast as the backend allows.
A key-value write passes through seven stages: client batching, server dispatch, partition routing, actor processing, write aggregation, Raft replication, and background persistence. The client batches requests over a shared gRPC stream. The server routes each request to the correct partition leader. The write aggregator batches multiple proposals into one Raft entry for throughput. Raft replicates the entry to a majority. The replicator applies the committed entry to all nodes. The background writer flushes data to disk asynchronously.
A key-value read follows the same routing path but diverges at the leadership check. Reads use a quorum-confirmed read-index check instead of a belief-based leader check. This prevents a partitioned leader from serving stale data. Reads do not create Raft proposals and do not go through the write aggregator.
The two asynchronous boundaries are Raft replication (the caller awaits the commit) and background persistence (the response returns before the disk flush). The unflushed overlay bridges the gap between Raft commit and disk flush.
The next chapter examines how Kahuna partitions data across nodes: how keys map to partitions, how partitions map to nodes, and how the system rebalances when nodes join or leave.
Chapter 15 traced a single request through every layer of the architecture. One step in that path was partition routing: the locator resolved a key to a partition number and found the leader for that partition. This chapter explains how that routing works, how partitions are created and split, and how the system rebalances data as it grows.
Partitioning determines three properties of the system: scalability (how much data and throughput a cluster can handle), data locality (which keys are stored together), and failure isolation (how much data is affected when a node goes down). A single partition is a bottleneck. Too many partitions waste resources. The right partitioning strategy depends on the workload.
Kahuna supports two ways to map keys to partitions: hash-based routing and key-range routing. Each mode has different trade-offs for data distribution and query patterns.
Hash-based routing is the default. Every key-space prefix that is not explicitly registered for key-range routing uses hash-based routing.
The routing works as follows. The DataPartitionRouter
extracts the key-space prefix from the key (the portion before the last
/ separator) and computes an ordinal hash of that prefix.
The hash maps to one of the partitions in a fixed pool.
The pool consists of partitions numbered from 1 to
InitialPartitions. Partition 0 is the meta partition
(described later in this chapter). It is excluded from the data pool.
The PoolSize equals the InitialPartitions
value from the Raft configuration, which is set at cluster startup with
the --initial-cluster-partitions flag (default 3).
DataPartitionRouter.Locate("orders/123/status")
→ prefix = "orders/123"
→ hash = HashUtils.InversePrefixedHash("orders/123/status", '/', PoolSize)
→ partitionId = hash + FirstUserPartitionId (1)
→ result: partition 2
Hash-based routing has two properties:
orders/100 and orders/101) may land on
different partitions. Range scans across prefixes require querying
multiple partitions.The partition pool is fixed. It does not grow or shrink. Partitions created by key-range splits receive higher IDs and are not part of the hash pool. Hash-based routing is static: the same key always maps to the same partition.
Because the pool is fixed, hash-based routing does not need a
generation fence. The routedGeneration for hash-routed keys
is always 0.
Key-range routing assigns contiguous intervals of keys to partitions. Keys that are close in sort order are stored on the same partition. This enables efficient range scans and prefix queries within a single partition.
Key-range routing is opt-in. A key space becomes key-range routed
only by explicit registration through the KeySpaceRegistry.
The registry maintains a per-node map from key-space prefixes to a
RoutingMode value: either Hash (the default)
or KeyRange.
When a key arrives, the KeySpaceRegistry extracts the
key space (the portion before the last /). It looks up the
routing mode for that key space. If the mode is KeyRange,
the request is routed through the RangeMap. If the mode is
Hash, the request goes through the
DataPartitionRouter as described above.
The registry uses a ConcurrentDictionary with
zero-allocation span lookups for performance. It reconciles its state
against the replicated range map on every range map update.
The RangeRouting static class is the single entry point
for all key-to-partition resolution. Both the
KeyValueLocator and the leader-side direct-write path call
through RangeRouting. This ensures that every routing
decision uses the same logic.
RangeRouting.Locate takes the registry, range map, data
partition router, and key. It returns a
(partitionId, generation) tuple. For hash-routed keys, the
generation is 0. For key-range keys, the generation comes from the
RangeDescriptor that covers the key.
RangeRouting.LocateWithMode returns additional
information: whether the key is key-range routed and, if so, the
covering RangeDescriptor. The locator uses this to check
for quiesced ranges and to carry the generation through inter-node
forwards.
The range map is an immutable in-memory data structure that maps
key-range spaces to their partition assignments. Each entry is a
RangeDescriptor.
A RangeDescriptor is a sealed record with these
fields:
| Field | Description |
|---|---|
KeySpace |
The key-space prefix (for example, "t:r"). |
StartKey |
Inclusive lower bound of the range. Null means negative infinity. |
EndKey |
Exclusive upper bound of the range. Null means positive infinity. |
PartitionId |
The Raft group that serves this range. |
Generation |
Incremented on every split, merge, or move. Used for generation fencing. |
QuiescedUntil |
Timestamp deadline until which writes are refused (data is in transit). |
QuiesceOwner |
The move operation that opened the quiesce window. |
QuiesceStartKey |
Start of the sub-interval being quiesced. |
QuiesceEndKey |
End of the sub-interval being quiesced. |
Bounds use ordinal string comparison. The interval is half-open:
[StartKey, EndKey). A descriptor with
StartKey = "a" and EndKey = "m" contains keys
from "a" up to but not including "m".
The Contains(key) method checks whether a key falls
within the half-open interval. The IsQuiescedAt(key, now)
method checks whether a key falls within the quiesced sub-interval and
the quiesce deadline has not lapsed.
The RangeMap groups descriptors by key space and sorts
them by StartKey within each group. Lookups use binary
search:
Find(keySpace, key): O(log n) search for the descriptor
that covers the key.FindCovering(key): extracts the key space from the key,
then calls Find.FindAll(keySpace): returns all descriptors for a key
space in sorted order.FindIntersecting(keySpace, startKey, endKey): O(log n +
k) for range queries, where k is the number of matching
descriptors.The range map enforces an invariant: within each key space,
descriptors must be contiguous with no gaps and no overlaps. The
Validate method checks this invariant and is called after
every mutation.
The RangeMapStore is the replicated source of truth for
the range map. It wraps an immutable RangeMap and
replicates changes through Raft on the meta partition (partition 0).
All mutations go through a single method:
MutateAsync(transform). This method serializes access with
a semaphore, validates the result, replicates the new descriptor set
through Raft, and swaps the in-memory map. Only the meta partition
leader can mutate the range map.
The store uses snapshot semantics: each replicated entry carries the full descriptor set, not a delta. Replay is idempotent. On startup, the store loads a durable snapshot from disk. Periodic checkpointing (every 32 mutations by default) writes a fresh snapshot and lets Kommander trim the write-ahead log.
Partition 0 is the meta partition. Chapter 14 introduced it briefly. This chapter adds the partitioning-specific details.
The meta partition stores the authoritative range map. Every range split, merge, or move replicates through partition 0’s Raft group. All nodes receive the updated range map through normal Raft replication.
The meta partition uses the same Raft protocol as data partitions. It has a leader, replicates to a majority, and supports state transfer for follower catch-up. Its special status comes from its content (the range map, transaction coordinator decisions, the MVCC retention boundary), not from a different mechanism.
Schema-log spaces (key spaces with a /meta suffix) are
never registered as key-range routed. The KeySpaceRegistry
enforces this rule.
When a key-range partition grows too large or too hot, the system
splits it into two smaller ranges. A split takes a range
[S, E) on partition P and produces two ranges:
[S, K) on the original partition P and [K, E)
on a new partition P’.
The RangeSplitter executes the split as a multi-step
transaction. The steps proceed in order:
Locate the covering range. Find the
RangeDescriptor for the range that contains the split key
K.
Validate bounds. Confirm that
S < K < E in ordinal order. Both halves must be
non-empty.
Check minimum size. Probe both halves to ensure
each has at least --range-split-min-range-size keys
(default 10). This prevents splits that produce ranges too small to be
useful.
Create the new partition. Call
CreatePartitionAsync with
RaftRoutingMode.Unrouted. The new partition P’ exists in
Raft but does not receive routed traffic yet.
Bulk copy. Copy all keys in [K, E)
from P to P’ at a consistent MVCC snapshot timestamp. The
KvStateMachineTransfer handles this copy in pages of 256
entries, each checksummed.
Quiesce. Acquire an exclusive range lock on
[K, E) within partition P. Publish a quiesce window on the
descriptor with a 30-second deadline
(QuiesceTtlMs = 30_000). While the range is quiesced,
writes to keys in [K, E) are rejected with
MustRetry. Settle any durable intents for keys in the
moving range.
Final catch-up copy. Capture writes that occurred between the MVCC snapshot and the quiesce point. Transfer range locks for the moving sub-interval. Gather and hand off transaction state: completion receipts, transaction records, and prepared intents.
Atomic cutover. Call
RangeMapStore.MutateAsync to replace the original
descriptor with two new descriptors: [S, K) on P with
generation+1 and [K, E) on P’ with generation+1. This is a
single Raft proposal on the meta partition. Both descriptors receive a
bumped generation.
Release quiesce. Release the exclusive range lock on the original partition. The quiesce window on the descriptor expires by its deadline.
After cutover, the original partition P still contains the
[K, E) rows. These orphan rows are unreachable through
routing and are not deleted immediately. They consume storage but do not
affect correctness.
The split must run on the meta partition (partition 0) leader. This ensures that range map mutations are serialized.
The quiesce mechanism uses two guards that work together:
Exclusive range lock. This is an actor-local lock that blocks writes at the actor processing level. It is ordered against concurrent writes: a write that arrives before the lock is acquired proceeds; a write that arrives after is rejected. This guard handles the local partition.
Descriptor quiesce. This is a replicated flag on
the RangeDescriptor. It survives leadership changes and is
visible to all nodes. It has a deadline (not a flag that must be
cleared), so it expires automatically if the split fails. This guard
handles forwarded requests from other nodes.
Both guards are necessary. The actor-local lock prevents races on the partition leader. The descriptor quiesce prevents a redirected request from bypassing the lock on a different node.
The RangeSplitTrigger monitors key-range partitions and
triggers splits automatically. It has two branches:
Count branch (slow cadence, approximately 60
seconds). The trigger samples the key count for each descriptor. When
the count reaches the --range-split-threshold (default 1000
keys), a split is triggered.
Load branch (fast cadence, approximately 5 seconds).
The trigger evaluates a predicate: operations per second must exceed
--range-split-load-threshold AND the write-ahead log queue
depth must exceed --range-split-load-min-queue-depth
(default 8). The predicate must hold for the full
--range-split-load-window (default 15 seconds) before a
split fires. This debounce prevents splits from transient load
spikes.
Both branches enforce a settle window. After a split, the two child
ranges are excluded from re-evaluation for
--range-split-settle-window seconds (default 10). This
prevents cascading splits.
An indivisibility guard prevents splits when the write-frequency histogram shows extreme imbalance (all writes target a single key). A relief guard skips load-based splits when no peer node is alive, because a single-node cluster gains no redistribution benefit from splitting.
All splits are serialized through a semaphore. Only one split runs at a time.
The RangeSplitPolicy computes the split key from an
ordered sample of keys in the range:
The policy clamps the split index so both halves have at least
--range-split-min-range-size keys.
When two adjacent key-range partitions are both under a minimum size,
the system merges them. A merge takes [A, B) on partition
P1 and [B, C) on partition P2 and produces
[A, C) on P1. Partition P2 is retired.
The RangeMerger follows a similar pattern to the
splitter:
EndKey of the
first equals StartKey of the second).[B, C) and publish a quiesce window on P2’s
descriptor.[B, C) on P2 to P1.[A, C) descriptor on P1 with generation+1.A merge copies in one direction only (source to destination), because merge candidates are small ranges.
The RangeMergeTrigger scans for adjacent pairs where
both ranges have fewer than --range-merge-min-size keys
(default 10). It runs on the meta partition leader. It skips warm
partitions (where operations per second exceed the load threshold) to
prevent merge-split oscillation: a range that was recently split due to
load should not be merged back immediately.
The generation fence prevents stale routing after a split or merge. When a range splits, the generation on both child descriptors is incremented. Any request that was routed with the old generation is rejected.
When a request is forwarded from one node to another, the
coordinating node includes a routedGeneration value. This
is the generation of the descriptor that the coordinating node used to
route the request.
On the receiving node,
RangeRouting.ResolveForDirectWrite checks the routed
generation against the live descriptor. The check returns one of four
results:
| Result | Meaning |
|---|---|
Ok |
The generation matches. The write is admitted. |
NoDescriptor |
The range moved away from this partition. The descriptor is gone. |
GenerationFenced |
The routed generation does not match the live generation. The routing is stale. |
Quiesced |
The range is in the middle of a split or merge. Writes are temporarily refused. |
When a request is fenced (GenerationFenced,
NoDescriptor, or Quiesced), the server returns
MustRetry. The client retries, and the routing layer
discovers the updated range map and sends the request to the correct
partition.
The fence is checked at two points:
The second check catches a race: the range map may update between the routing decision and the proposal submission.
Hash-routed keys do not use the generation fence. Their routing is static (the pool never changes), so the generation is always 0.
The write aggregator batches multiple proposals for efficiency
(Chapter 15). A proposal may sit in the aggregator’s buffer while a
split completes. Before flushing the buffer, the aggregator calls
RangeRouting.HasKeyRangeMovedSinceAdmission to check
whether any buffered key’s range moved since the key was admitted. Stale
items are released with a retryable failure.
The KvStateMachineTransfer handles the bulk copy of data
during splits and merges.
The export reads keys in the range [startKey, endKey) at
a consistent MVCC snapshot. It pages through the data in batches of 256
entries. Each page is checksummed with FNV hash for integrity
verification.
Only persistent keys are transferred. Ephemeral (in-memory-only) data is not transferable.
The import applies entries to the destination partition’s persistence backend. The import is idempotent: applying the same page twice produces the same result.
Range locks are transferred separately. The
FilterAndClamp method filters the source partition’s lock
state for locks that fall within the moving sub-interval. These locks
are re-imported on the destination partition.
If leadership changes during the transfer,
EnsureLocksOnDestinationLeaderAsync re-imports the locks on
the new leader.
Prefix operations (GetByBucket and
ScanAllByPrefix) scan all keys that share a prefix. When a
key-range space splits, the prefix scan on a single partition may miss
data that moved to the new partition.
RangeRouting.IsPrefixOpSafe checks whether a key-range
space was split in a way that would make a prefix scan incomplete. If
the check returns false, the operation must query multiple partitions or
return an error.
These command-line flags control partitioning behavior:
| Flag | Default | Description |
|---|---|---|
--initial-cluster-partitions |
3 | Number of partitions at cluster startup. |
--range-split-threshold |
1000 | Key count that triggers an automatic split. |
--range-split-min-range-size |
10 | Minimum keys per half after a split. |
--range-split-settle-window |
10s | Cooldown after a split before re-evaluation. |
--range-merge-min-size |
10 | Key count below which adjacent ranges may merge. |
--range-collection-interval |
60s | Interval between count-based sampling passes. |
--range-split-load-threshold |
0 (disabled) | Operations per second for load-based split. |
--range-split-load-min-queue-depth |
8 | WAL queue depth gate for load-based split. |
--range-split-load-window |
15s | Sustained load window before a split fires. |
--range-split-load-poll-interval |
5s | Poll frequency for load signals. |
Load-based splitting is disabled by default
(--range-split-load-threshold is 0). Enable it by setting a
non-zero threshold.
MustRetry.The generation fence prevents the write from landing on a partition that no longer owns the key.
MustRetry.RetryableTransaction, the transaction
starts over with a fresh session.The transaction is not corrupted. The split causes a transient failure, and the retry mechanism handles it.
RangeSplitTrigger cleans up the orphaned partition
P’ that was created in step 4.Because the atomic cutover (step 8) did not execute, the range map is unchanged. No data is lost.
RangeSplitPolicy detects that the write-centroid
imbalance is at the maximum (1.0).A range with a single hot key cannot be split further. The application must distribute writes across multiple keys to avoid this bottleneck.
Kahuna distributes keys across partitions using two routing modes. Hash-based routing is the default: a fixed pool of partitions, uniform distribution, no key locality. Key-range routing is opt-in: contiguous key intervals on the same partition, efficient range scans, but requires range management.
The RangeMap stores the mapping from key intervals to
partitions. The RangeMapStore replicates it through the
meta partition (partition 0). Every mutation is a single Raft
proposal.
Range splits follow a multi-step protocol: bulk copy, quiesce, catch-up copy, atomic cutover. The quiesce mechanism uses two guards (an actor-local lock and a replicated descriptor deadline) to prevent writes during the transition. The generation fence rejects requests that use a stale routing generation.
Automatic triggers split ranges when they grow too large (count-based) or too hot (load-based). Automatic merges recombine small adjacent ranges. Both triggers use settle windows and guards to prevent oscillation.
The next chapter examines Raft consensus and replication: how Kahuna uses Kommander for leader election, log replication, and state transfer.
Every write in Kahuna goes through Raft consensus. Every linearizable read confirms leadership through a quorum check. Raft is the foundation that makes Kahuna fault-tolerant: it ensures that all replicas agree on the same ordered sequence of operations, even when nodes crash or networks partition.
This chapter explains how Kahuna uses Raft. It covers the consensus problem, the Raft algorithm, per-partition Raft groups, log entry types, linearizable reads, Hybrid Logical Clocks, and the replication pipeline that applies committed entries to each subsystem.
A distributed system that stores data on multiple nodes must answer a question: when two nodes disagree about the state of a key, which one is correct?
Without a consensus protocol, the answer is undefined. A network partition can leave two nodes with different values for the same key, and neither node knows which value is authoritative. This is the split-brain problem.
Raft solves split-brain by electing a single leader for each group of replicas. All writes go through the leader. The leader replicates each write to a majority of nodes before the write is committed. A write that is committed on a majority cannot be lost, even if a minority of nodes crash.
The guarantee is precise: Raft ensures that all nodes apply the same sequence of log entries in the same order. If entry N is committed, every node that is alive and reachable will eventually have entry N at position N in its log. This is the foundation of consistency in Kahuna.
Kahuna does not implement Raft itself. It uses Kommander, an external
Raft library. Kommander provides the IRaft interface, which
exposes:
Kahuna registers callbacks with Kommander to receive events: committed log entries, leadership changes, and restore notifications. Kommander handles the protocol. Kahuna handles the state.
Chapter 16 described how Kahuna distributes keys across partitions. Each partition has its own independent Raft group. This means each partition has its own leader, its own log, and its own election cycle.
Per-partition groups provide two benefits:
Independent leadership. Different partitions can have different leaders. If node A leads partitions 1 and 2, and node B leads partition 3, a failure of node A triggers elections only for partitions 1 and 2. Partition 3 is unaffected.
Independent throughput. Each Raft group processes proposals independently. A slow proposal on partition 1 does not block proposals on partition 2.
Kommander creates a partition with
CreatePartitionAsync(partitionId, mode, hashRange, cancellationToken).
The mode parameter specifies the routing mode (routed or
unrouted). Unrouted partitions are created during range splits before
the cutover (Chapter 16).
The default cluster starts with a configurable number of partitions
(--initial-cluster-partitions, default 3). Partition 0 is
the meta partition. Partitions 1 through N are data partitions. Range
splits create additional partitions with higher IDs.
Each Raft group needs a thread to process proposals and heartbeats.
With many partitions, a dedicated thread per partition wastes resources.
Kommander provides a shared executor pool
(--raft-enable-shared-executor-pool, default true) that
multiplexes all partition executors onto a bounded thread pool. This is
necessary for clusters with hundreds or thousands of partitions.
Idle partitions that receive no proposals and no client reads are
quiesced (--raft-enable-quiescence, default true). A
quiesced partition stops sending per-partition heartbeats and relies on
the SWIM protocol for failure detection. When a new proposal or read
arrives, the partition wakes up.
Quiescence reduces network traffic in clusters with many partitions
where only a fraction are active at any time. The
QuiesceAfter timeout (default 1500 ms) controls how long a
partition must be idle before it quiesces.
Raft uses heartbeat-based leader election. The leader sends periodic heartbeat messages to all followers. If a follower does not receive a heartbeat within its election timeout, it becomes a candidate and starts an election.
Kommander uses randomized election timeouts to prevent split votes:
| Parameter | Default | Description |
|---|---|---|
HeartbeatInterval |
500 ms | How often the leader sends heartbeats to followers. |
StartElectionTimeout |
2000 ms | Lower bound of the randomized election timeout. |
EndElectionTimeout |
4000 ms | Upper bound of the randomized election timeout. |
VotingTimeout |
1500 ms | How long a candidate waits for a quorum of votes. |
CheckLeaderInterval |
250 ms | How often the timer fires to check leader state. |
TimerInitialDelay |
2500 ms | Grace period after startup before timers fire. |
Each follower picks a random timeout between
StartElectionTimeout and EndElectionTimeout.
The randomization ensures that followers do not all start elections at
the same time after a leader failure. The follower with the shortest
timeout becomes a candidate first and usually wins the election.
If an election fails (no candidate receives a majority), each
candidate adds a random increment between
StartElectionTimeoutIncrement (100 ms) and
EndElectionTimeoutIncrement (200 ms) to its timeout and
tries again. This further spreads out retries.
The critical constraint: HeartbeatInterval must be less
than StartElectionTimeout. If heartbeats are slower than
the election timeout, followers start unnecessary elections.
Kommander uses a pre-vote protocol. Before a follower starts a real election (which increments the term and forces other nodes to step down), it sends a pre-vote request to check whether it would win.
A pre-vote does not increment the term. If the pre-vote fails (the majority does not respond or already has a leader), the follower does not disrupt the cluster. This prevents a problem where a partitioned node repeatedly increments its term and forces the healthy majority to step down when the partition heals.
RequestVote messages to all
voters.LeadershipBarrierTimeout (default 10
seconds) is the maximum wait for this barrier to commit.When a partition’s leader changes, Kommander calls the
OnLeaderChanged callback. Kahuna receives this through the
ReplicationService and notifies all subsystem managers.
This lets each manager update its local routing state and reject
requests that target partitions where this node is no longer the
leader.
Every durable mutation in Kahuna is serialized into a Raft log entry.
The ReplicationSerializer encodes and decodes these
entries. Each entry carries a type string that identifies its
content.
| Type String | Content | Partition |
|---|---|---|
"kv" |
Key-value mutations: set, delete, extend. | Data partition |
"lock" |
Lock mutations: lock, unlock, extend. | Data partition |
"rangemap" |
Range-descriptor map snapshot. | Meta partition (0) |
"snapshotfloor" |
Snapshot-floor hold registry (MVCC retention boundary). | Meta partition (0) |
"coorddecision" |
Durable coordinator decision record delta (2PC commit/abort). | Data partition |
"receipt" |
Completion receipts (used during split/merge handoff). | Data partition |
"txnrecord" |
Transaction record transitions (init, commit, abort). | Data partition |
"preparedintent" |
Prepared-intent transitions (prepare, resolve, remove). | Data partition |
Key-value and lock mutations are the most common types. They represent the application-visible writes. The remaining types are internal coordination state for transactions, range management, and MVCC.
Kommander exposes three methods for submitting log entries:
ReplicateLogs(partitionId, type, data): submit a single
entry.ReplicateLogs(partitionId, type, IReadOnlyList<byte[]>):
submit a homogeneous batch (all entries share the same type
string).ReplicateEntries(partitionId, IReadOnlyList<RaftProposalEntry>):
submit a heterogeneous batch where each entry can have a different
type.Chapter 15 described how the
PartitionWriteAggregatorActor batches key-value writes. It
uses ReplicateEntries to submit multiple KV mutations as a
single Raft proposal. Lock mutations use individual
ReplicateLogs calls.
Some proposals use explicit two-phase commit within Raft. The
ReplicateLogs method accepts an autoCommit
parameter. When autoCommit is false, the entry is proposed
but not committed until the caller explicitly calls
CommitLogs(partitionId, ticketId) or
RollbackLogs(partitionId, ticketId). Kahuna uses this for
transaction coordination.
When Raft commits a log entry, every node in the group must apply it to its local state. Kahuna uses a replicator/restorer pattern for this.
During normal operation, Kommander calls the
OnReplicationReceived callback for each committed log
entry. The ReplicationService forwards this to
KahunaManager, which dispatches based on the entry
type.
The KeyValueReplicator handles "kv"
entries. For each committed key-value mutation (TrySet,
TryDelete, TryExtend), it performs four steps
in order:
Register pending. The durability tracker records that this log entry is pending, which prevents the durability floor from advancing past it until the background writer flushes it to disk.
Record in the unflushed overlay. The unflushed write index caches the committed value so reads can see it before the background writer flushes it.
Enqueue for persistence. The replicator sends a
QueueStoreKeyValue message to the
BackgroundWriterActor for asynchronous disk flush.
Invalidate or apply on the actor. The replicator
sends an InvalidateOrApply message to the
KeyValueActor through the consistent-hash router. On the
leader, the actor invalidates its cached entry (it already has the value
from processing the original request). On followers, the actor applies
the new value.
After these steps, the replicator records a completion receipt. It also records the write in the write-frequency registry for key-range partitions, which the split trigger uses.
The LockReplicator follows the same pattern for
"lock" entries: register pending, record in the unflushed
lock overlay, enqueue for persistence, and send
InvalidateOrApply to the LockActor.
When a node starts up or a new leader is elected, committed log
entries must be replayed to rebuild in-memory state. Kommander calls the
OnLogRestored callback for each entry in the write-ahead
log.
The KeyValueRestorer handles "kv" entries
during restore. It performs a simpler sequence than the replicator:
The restorer does not send InvalidateOrApply to actors.
During restore, actors are not yet active. They are rebuilt after the
restore completes.
The LockRestorer follows the same simplified pattern for
lock entries.
The ReplicationService is an ASP.NET
BackgroundService that wires Raft events to
IKahuna:
raft.OnLogRestored → kahuna.OnLogRestored
raft.OnReplicationReceived → kahuna.OnReplicationReceived
raft.OnReplicationError → kahuna.OnReplicationError
raft.OnLeaderChanged → kahuna.OnLeaderChanged
raft.OnMembershipChanged → OnMembershipChanged
On startup, the service joins the cluster (either as a new member or
by joining an existing cluster with --join-existing). On
shutdown, it unwires events and optionally performs a graceful leave
(--graceful-leave-on-shutdown).
Chapter 15 described the read path: the locator calls
ConfirmLeadershipForRead before serving a read. This
section explains the protocol in detail.
A node that believes it is the leader can serve reads from its local state. But what if the node is no longer the leader? A network partition can isolate a node from the majority. The isolated node still believes it is the leader (no heartbeat timeout fired yet), but a new leader was elected on the majority side. The isolated node’s state is stale.
If the isolated node serves reads from its local state, those reads return stale data as a successful response. There is no replication step (as with writes) to catch the stale belief. The client receives outdated values and does not know they are outdated.
ConfirmLeadershipAsync implements the read-index
protocol from section 6.4 of the Raft dissertation. The protocol works
in three steps:
Record the current commit index. The leader notes the highest committed log index at the time the read starts.
Confirm leadership with a quorum. The leader
sends a lightweight message to all followers. A majority must respond,
confirming that they still recognize this node as their leader in the
current term. If the majority does not respond within
LeadershipConfirmationTimeout (default 2 seconds), the
confirmation fails.
Wait for local application. The leader waits until its local state machine has applied all entries up to the recorded commit index. This ensures the read reflects all committed writes.
If all three steps succeed, the read is linearizable: it reflects every write that committed before the read started, and no write that committed after.
Multiple concurrent reads can share a single confirmation round. When
several ConfirmLeadershipAsync calls overlap, they coalesce
into one quorum check. A confirmation that completed within the last
heartbeat interval is reused for subsequent reads. This means that under
steady load, the cost is approximately one quorum round-trip per
heartbeat interval, regardless of read volume.
Two leadership checks serve different purposes:
| Method | Mechanism | Use Case |
|---|---|---|
AmILeaderQuick |
Local published state. No network call. | Write path: sufficient because a write proposal on a deposed leader fails at the Raft replication step. |
ConfirmLeadershipAsync |
Quorum confirmation. Network round-trip. | Read path: necessary because reads have no replication step to catch a stale leader. |
Writes are self-validating. A write proposal submitted to a deposed leader is rejected by Raft because the deposed leader cannot replicate to a majority. The proposal fails, and the client retries.
Reads are not self-validating. A deposed leader can serve stale data from its local state without any rejection mechanism. The quorum confirmation is the mechanism that prevents this.
Kahuna uses Hybrid Logical Clocks (HLC) for cross-node event ordering. An HLC timestamp combines three components:
| Component | Type | Description |
|---|---|---|
L |
long | Physical timestamp in unix epoch milliseconds. |
C |
uint | Logical counter. Differentiates events with the same physical time. |
N |
int | Node ID. Breaks ties between events on different nodes. |
HLC timestamps form a total order. Comparison follows a priority
chain: L first, then C, then N.
Two events on different nodes with the same physical timestamp are
ordered by their counter. Two events with the same timestamp and counter
are ordered by node ID. No two events produce the same
(L, C, N) tuple.
The clock advances differently for local events and received messages:
Local event (send or local). The clock computes
newL = max(currentL, physicalTime). If newL
equals currentL, the counter increments. If
newL is greater, the counter resets to zero. The clock
swaps the new value using a lock-free compare-and-swap (CAS) loop. Zero
allocation per event.
Receive event. The clock computes
newL = max(currentL, messageL, physicalTime). If all three
are equal, the counter is max(currentC, messageC) + 1. This
is the standard HLC receive algorithm from Kulkarni et al.
The HLC packs L and C into a single 64-bit
long: the high 42 bits store L (unix milliseconds, valid to
approximately year 2109), and the low 22 bits store C
(approximately 4.19 million events per millisecond). If the counter
overflows 22 bits, the clock rolls to (L+1, 0) to preserve
causality.
Physical clocks alone are insufficient for ordering events across nodes, because wall clocks drift. Logical clocks (Lamport clocks) provide causal ordering but have no relationship to physical time. HLC combines both: it tracks causality like a logical clock and stays close to physical time like a wall clock. This makes HLC timestamps useful both for ordering (MVCC, conflict detection) and for human-readable timestamps (last-modified fields, expiration deadlines).
Each node in a Raft cluster has a role that determines its participation in consensus:
| Role | Votes? | Receives Replication? | Description |
|---|---|---|---|
Voter |
Yes | Yes | Full participant. Counts toward quorum in elections and commits. |
Learner |
No | Yes | Receives committed entries but does not vote. Cannot become leader. |
Leaving |
No | Yes | Committed during graceful decommission. Replicas are evacuated to other nodes. |
A new node joins the cluster as a learner. It receives committed log
entries and builds up its state without affecting the cluster’s quorum
size. When the learner’s log catches up to within
LearnerPromotionLag entries (default 10) of the leader and
stays within that threshold for
LearnerPromotionStableWindow (default 3 seconds), Kommander
promotes it to voter automatically.
This two-phase join prevents a slow new node from reducing the cluster’s availability. If a learner joined as a voter immediately, its slow replication would block commits (the quorum would include a node that is far behind).
When a node is decommissioned with
--graceful-leave-on-shutdown, it transitions to the
Leaving role. The cluster evacuates its partition replicas
onto surviving nodes. After evacuation completes, the node is removed
from the cluster roster.
The replication factor controls how many copies of each partition exist in the cluster. The default is 0, which means full replication: every voter hosts every partition. This is appropriate for small clusters (3 to 5 nodes).
For larger clusters, a fixed replication factor (for example, 3)
limits each partition to a subset of nodes. This reduces storage and
network overhead. The replication factor can be set per partition with
SetReplicationFactorAsync.
When a follower falls far behind the leader’s log (because it was offline or the WAL was compacted), it cannot catch up by replaying log entries. The entries it needs are gone.
Kommander handles this with state transfer. The leader sends a snapshot of the partition’s state to the follower. Kahuna registers three state transfer handlers:
IRaftStateMachineTransfer: transfers key-value data
during range splits and merges (Chapter 16).IRaftSystemStateTransfer: transfers system state (meta
partition) for node repair.IRaftPartitionStateTransfer: transfers user data
partition state for follower catch-up.After the snapshot is applied, the follower resumes normal log replication from the leader’s current position.
The write-ahead log grows as entries accumulate. Periodically,
Kommander compacts the WAL by discarding entries that all nodes have
applied. The CompactEveryOperations setting (default
10,000) controls how often compaction runs.
Before compacting, the system checks retention holds. The
SetMinRetainIndex and AcquireRetentionHold
methods let subsystems prevent compaction of entries they still need
(for example, entries needed for state transfer).
After compaction, entries before the compaction point are gone. A follower that needs those entries must use state transfer instead of log replay.
RequestVote messages, and wins the election.Committed entries are not lost. The new leader has all committed entries (Raft’s election rule ensures the candidate with the most up-to-date log wins).
ConfirmLeadershipAsync is used (the quorum check fails).
Without the quorum check, the minority node would serve stale data.The pre-vote protocol reduces the impact of split votes. A candidate that loses the pre-vote does not increment its term, so the cluster’s term does not advance unnecessarily.
Uncommitted entries are lost. This is correct: they were never acknowledged to the client (the proposal did not succeed), so no data loss occurs from the client’s perspective.
ConfirmLeadershipAsync. The quorum check
fails because node A cannot reach a majority.MustRetry. The client retries on another
node and reaches the current leader.Without ConfirmLeadershipAsync, step 5 would not exist.
Node A would serve stale data from its local state, and the client would
receive outdated values without any indication.
Kahuna uses Raft consensus through the Kommander library. Each partition has its own Raft group with independent leadership, log, and election cycle. The shared executor pool and partition quiescence make this practical for clusters with many partitions.
Leader election uses randomized timeouts and a pre-vote protocol to prevent disruption. Committed entries are applied through the replicator/restorer pattern: replicators handle normal operation; restorers handle startup and recovery.
Linearizable reads use the read-index protocol: the leader confirms its authority with a quorum before serving a read. Concurrent confirmations coalesce for efficiency. This prevents a deposed leader from serving stale data.
Hybrid Logical Clocks provide total ordering across nodes by combining physical time with a logical counter. HLC timestamps are used for MVCC, conflict detection, and human-readable timestamps.
The replication pipeline transforms committed log entries into state changes across all subsystems. Eight log entry types cover key-value mutations, lock mutations, range map changes, transaction coordination, and MVCC retention.
The next chapter examines the persistence layer: how Kahuna stores data on disk, how the background writer flushes committed entries, and how the unflushed overlay bridges the gap between Raft commit and disk write.
Traditional concurrent data structures use locks. A thread acquires a lock, reads or writes shared state, and releases the lock. This works, but it is error-prone: deadlocks, priority inversions, and forgotten unlock calls are common sources of bugs in concurrent systems.
Kahuna takes a different approach. It uses the actor model for concurrency. Each partition’s state is owned by an actor that processes messages one at a time in a single-threaded loop. There are no locks on the in-memory state within an actor. Concurrency comes from having many actors, not from shared-memory parallelism within one.
This chapter explains how Kahuna uses the Nixie actor system, how messages are routed to actors, and how the handler pattern structures actor logic.
The actor model has three rules:
These three rules eliminate data races by construction. If only one thread ever touches the state, there is no race. If actors communicate only through messages, there is no shared mutable state.
The trade-off is that actors cannot share data structures. If two actors need the same data, one must send a message to the other. This adds latency compared to a direct memory read, but it removes the need for synchronization.
Kahuna uses Nixie, an external actor framework. Nixie provides three building blocks: the actor interface, the actor reference, and the actor system.
Nixie defines two variants of the actor interface:
Fire-and-forget. The sender does not wait for a response.
public interface IActor<in TRequest> where TRequest : class
{
public Task Receive(TRequest message);
}Request/response. The sender awaits a response.
public interface IActor<in TRequest, TResponse>
where TRequest : class
where TResponse : class?
{
public Task<TResponse?> Receive(TRequest message);
}Both variants have a single method: Receive. The actor
system calls this method for each message in the actor’s inbox. The
actor processes the message and returns a result (or completes the task
for fire-and-forget actors).
An IActorRef is a handle for sending messages to an
actor. It provides:
Send(message): fire-and-forget delivery. The message is
enqueued and the caller continues.Ask(message): request/response delivery. The caller
awaits the result.TrySend(message): returns false if the inbox is full
(bounded inbox mode).TryAsk(message): returns false and a null task if the
inbox is full.The caller never accesses the actor’s internal state. All interaction goes through the reference.
The ActorSystem is the container that manages actor
lifecycles. It provides:
Spawn<TActor, TRequest, TResponse>(name, args):
create an actor with constructor arguments.SpawnWithOptions<>(): create an actor with inbox
bounds and control message classifiers.Get<>(name): find an actor by name.StartPeriodicTimer: send a message to an actor on a
repeating interval.ScheduleOnce: send a message to an actor after a
delay.Shutdown<> / GracefulShutdownAll:
stop actors and drain their inboxes.Each actor has an inbox implemented as a
ConcurrentQueue. When a message arrives, the actor runner
enqueues it and checks whether the processing loop is idle. If idle, it
schedules the actor on the thread pool.
The processing loop drains messages one at a time:
await Actor.Receive(message).A compare-and-swap flag (processing) ensures that only
one thread runs the loop at any time. This is the single-threaded
guarantee: even though the actor runs on pool threads, it never runs on
two threads simultaneously.
The actor runner uses ThreadPool.UnsafeQueueUserWorkItem
instead of Task.Run to avoid ExecutionContext
capture overhead. This is a performance optimization for high-throughput
actors.
An actor can be created with a maximum inbox size. When the inbox is
full, Send and Ask throw an
ActorBusyException. The message is never enqueued. The
caller can catch this exception and return MustRetry to the
client.
Some messages are classified as control messages. Control messages bypass the inbox bound and are delivered ahead of ordinary messages. Within each class (control vs ordinary), messages are delivered in FIFO order. Control messages overtake the ordinary backlog but maintain order among themselves.
In Kahuna, the following message types are classified as control
messages for KeyValueActor:
CompleteProposal and ReleaseProposal (Raft
proposal lifecycle)InvalidateOrApply (follower replication)ResumeRead (deferred disk reads)FlushAck (durability floor advancement)Collect (periodic garbage collection)GetSafeTimestamp (MVCC timestamp queries)These messages must not be blocked by a full inbox. A
CompleteProposal message that is rejected would leave a
Raft proposal permanently pending.
Each partition can have multiple actor instances. Messages are routed to a specific instance based on the key’s hash. This allows parallelism within a partition for non-overlapping keys.
Nixie provides a ConsistentHashActorStruct router. The
router creates N instances of the actor type. When a message arrives,
the router computes the target instance:
instance = instances[(message.GetHash() & int.MaxValue) % instances.Count]
The message must implement IConsistentHashable, which
exposes a GetHash() method. The hash of the key determines
which actor instance processes the message.
This is a simple modulo routing, not a virtual-node ring. The same key always routes to the same actor instance. Different keys may route to different instances, which allows concurrent processing of messages for non-overlapping keys within the same partition.
The number of actor instances per partition is set by the
KeyValueWorkers configuration. More workers means more
parallelism within a partition, but also more memory (each actor holds
its own B-tree cache).
Kahuna uses five types of actors, each with a distinct responsibility:
| Actor | Interface | Role |
|---|---|---|
KeyValueActor |
IActor<KeyValueRequest, KeyValueResponse> |
Holds key-value entries for a partition. Processes reads, writes, and transaction operations. |
LockActor |
IActor<LockRequest, LockResponse> |
Holds lock state for a partition. Processes lock, unlock, and extend operations. |
SequenceActor |
IActor<SequenceRequest, SequenceResponse> |
Holds sequence block allocation state. Serves next-value requests from a reserved block. |
PartitionWriteAggregatorActor |
IActor<PartitionWriteMessage, PartitionWriteAck> |
Batches key-value write proposals for Raft replication. |
BackgroundWriterActor |
IActor<BackgroundWriteRequest> |
Flushes committed Raft entries to the persistence backend. Fire-and-forget. |
The first three actors hold partition state. The last two are infrastructure actors that handle the write pipeline.
The KeyValueActor is the most complex actor in Kahuna.
It holds the in-memory state for key-value entries in a partition and
dispatches operations to handler classes.
The actor holds four data structures:
B-tree cache. A
BTree<string, KeyValueEntry> with order 32. The
B-tree stores key-value entries in sorted order by key. The sorted
structure enables efficient prefix scans (ScanByPrefix) and
range scans (GetByRange). The B-tree is a custom
implementation in Kahuna.Core/Utils/BTree.cs.
Prefix write intents. A
Dictionary<string, KeyValueWriteIntent> keyed by
prefix. When a transaction operates on a bucket (prefix scan), it
registers a write intent on the prefix. Other transactions that touch
the same prefix detect the intent and handle conflicts.
Range locks. A
Dictionary<string, List<KeyValueRangeLock>>
keyed by prefix. Range locks protect sub-intervals within a key space
during splits and transactions. Multiple non-overlapping range locks can
coexist on the same prefix.
In-flight proposals. A
Dictionary<int, KeyValueProposal> keyed by proposal
ID. Each entry tracks a Raft proposal that is awaiting commit. When the
replicator sends a CompleteProposal message, the actor
resolves the proposal’s TaskCompletionSource.
The KeyValueActor constructor creates approximately 25
handler instances. All handlers share a KeyValueContext
object that provides access to the actor’s state, the persistence
backend, and the Raft interface.
The Receive method dispatches messages to handlers based
on the message type:
public async Task<KeyValueResponse> Receive(KeyValueRequest message)
{
switch (message.Type)
{
case KeyValueRequestType.TrySet:
return await trySetHandler.Execute(message);
case KeyValueRequestType.TryGet:
return await tryGetHandler.Execute(message);
case KeyValueRequestType.TryDelete:
return await tryDeleteHandler.Execute(message);
// ... ~25 more cases
}
}Each handler is a class with an Execute method that
takes a KeyValueRequest and returns a
KeyValueResponse. The handler classes live in
Kahuna.Core/KeyValues/Handlers/. There are 41 handler files
covering all key-value operations:
TrySetHandler,
TryGetHandler, TryDeleteHandler,
TryExtendHandler, TryExistsHandler.TryGetByBucketHandler,
TryGetByRangeHandler,
TryScanByPrefixHandler.TryPrepareMutationsHandler,
TryCommitMutationsHandler,
TryRollbackMutationsHandler.TryAcquireExclusiveLockHandler,
TryAcquireExclusivePrefixLockHandler,
TryAcquireExclusiveRangeLockHandler.CompleteProposalHandler,
InvalidateOrApplyHandler, ResumeReadHandler,
EvictPartitionHandler, TryCollectHandler.The handler pattern keeps the Receive method short. Each
handler encapsulates one operation’s logic: input validation, state
lookup, conflict detection, proposal creation, and response
construction.
The actor implements two layers of backpressure:
Inbox bound. If the actor’s inbox is full, the
consistent-hash router catches the ActorBusyException and
returns MustRetry. The message is never enqueued.
Read backpressure. If the persistence backend’s
read scheduler is saturated, the handler throws a
ReadBackpressureExceededException. The actor catches it and
returns MustRetry.
Both layers protect the system from overload. The client retries after a short delay.
Every 500 operations, the actor checks whether its memory budget is
exceeded (IsOverBudget()). If so, it sends itself a
Collect message. The collection handler evicts expired
entries from the B-tree cache. Because Collect is a control
message, it bypasses the inbox bound and is processed promptly.
The KeyValueActorRouters class creates two separate
consistent-hash rings of KeyValueActor instances:
Persistent ring. For keys with
KeyValueDurability.Persistent. These actors use the shared
prepared-intent store and transaction-record store. Writes go through
Raft replication and are flushed to disk.
Ephemeral ring. For keys with
KeyValueDurability.Ephemeral. These actors get empty
(no-op) prepared-intent and transaction-record stores. Writes are
in-memory only: no Raft replication, no disk persistence. Data is lost
on restart.
The locator selects the ring based on the durability of the operation. This separation prevents ephemeral operations from contaminating the transaction state of persistent operations.
Each ring has KeyValueWorkers actor instances. Both
rings use bounded inboxes with the same control message classifier.
The LockActor holds the lock state for a partition. Its
state is simpler than the KeyValueActor:
Dictionary<string, LockEntry> locks: lock entries
keyed by resource name. Each entry records the owner, expiry, and
fencing token.Dictionary<int, LockProposal> proposals:
in-flight Raft proposals.HashSet<string> keysToEvict: eviction set for
expired locks.The Receive method dispatches to internal methods for
TryLock, TryUnlock,
TryExtendLock, Get,
CompleteProposal, ReleaseProposal,
InvalidateOrApply, and EvictPartition.
Lock proposals use a LockProposalActor through a
balancing router (round-robin), not a consistent-hash router. Locks
submit one Raft proposal per operation (Chapter 15), so there is no need
for key-based routing of proposals.
The actor runs periodic collection every 500 operations to evict expired locks.
The SequenceActor holds block allocation state for
sequences:
Dictionary<string, SequenceBlock> blocks: one
block per sequence name. Each block is a reserved window of IDs (a
low-water mark and a high-water mark).When a NextSequenceValue request arrives, the actor
checks the block for the named sequence. If the block has remaining IDs,
the actor returns the next value from memory. No Raft proposal is
needed.
If the block is exhausted, the actor allocates a new block by writing the updated high-water mark to the key-value subsystem (sequences store their durable state as key-value entries). This write goes through the full KV pipeline, including Raft replication.
A useStamp counter tracks LRU access for eviction of
resident blocks when memory is constrained.
If a CancellationToken fires during block allocation
(the KV write times out or is cancelled), the actor drops the block. It
cannot know whether the write committed. On the next request, a fresh
block allocation reads the durable high-water mark and starts from the
correct position.
The PartitionWriteAggregatorActor batches key-value
write proposals for efficiency (Chapter 15 described the batching
protocol in detail). It is a Nixie actor
(IActor<PartitionWriteMessage, PartitionWriteAck>)
with per-partition state in a dictionary.
The actor never awaits Raft on the mailbox thread. When a batch is
ready, it dispatches the Raft replication as a detached operation. The
Raft result arrives later as a BatchComplete control
message, which the actor processes to resolve the individual
proposals.
Before dispatch, each item is re-checked against the range map. If a key’s range moved since the item was admitted (a split completed while the item was queued), the item is released with a retryable failure. This prevents stale writes from reaching Raft. A maximum of 1,024 releases per dispatch prevents starvation of the processing loop.
The BackgroundWriterActor is a fire-and-forget actor
(IActor<BackgroundWriteRequest>) that flushes
committed Raft entries to the persistence backend. It receives
QueueStoreKeyValue and QueueStoreLock messages
from the replicators.
The actor batches writes: up to 1,024 items or 512 KB per flush. It retries failed writes up to 5 times with backoff. A revision cleanup queue (capped at 10,000 entries) removes old revisions after the MVCC retention window expires.
The actor model provides safety through a contract: no shared mutable state across actors, and no concurrent access to an actor’s state.
This contract holds as long as the actor does not leak references to
its internal state across await points. Consider this
violation:
// INCORRECT: reference to internal state escapes the actor
var entry = keyValuesStore.Get(key);
// This await yields the thread. Another message could modify
// keyValuesStore before the continuation runs.
await SomeAsyncOperation();
// entry may now be stale or invalid.
entry.Value = newValue;Between the await and the continuation, the actor
processes no other messages (Nixie’s single-threaded guarantee holds).
But if the reference escapes to a callback or a detached task, two
threads could touch the state simultaneously.
Kahuna’s handlers follow a discipline: read state, compute the result, create a proposal if needed, and return. Long-running operations (disk reads, Raft proposals) are handled by sending a message to the actor when the operation completes, not by holding a reference across the await.
Write intents, prefix locks, and range locks live in actor memory. They are not persisted and not replicated through Raft. When a leader change occurs:
MustRetry or Aborted.This is safe because write intents are optimistic: they record what a transaction intends to do. If the leader changes before the transaction commits, the transaction is aborted and retried on the new leader. The committed state (in Raft) is authoritative. The in-memory state (in actors) is transient.
KeyValueActor’s inbox to
its maximum size.Send and Ask calls throw
ActorBusyException.MustRetry to the client.Control messages (proposal completions, replication updates) bypass the inbox bound. This ensures that the Raft pipeline is not blocked by client request backpressure.
Kahuna uses the Nixie actor system for concurrency. Each actor owns its state and processes messages one at a time. There are no locks on in-memory state within an actor. Data races are eliminated by construction.
Messages are routed to actor instances through consistent-hash routing. The key’s hash determines which actor instance processes the message. Multiple instances per partition allow parallelism for non-overlapping keys.
The KeyValueActor is the most complex actor. It holds a
B-tree cache, write intents, range locks, and in-flight proposals. It
dispatches operations to 41 handler classes through a switch on the
message type.
Two separate actor rings (persistent and ephemeral) prevent cross-contamination between durable and in-memory-only operations. Bounded inboxes with control message bypass provide backpressure without blocking the Raft pipeline.
The single-threaded invariant is the foundation of the actor model’s safety. As long as actors do not leak references to their internal state across async boundaries, the invariant holds and the state is consistent.
The next chapter examines MVCC and snapshot isolation: how Kahuna tracks multiple versions of a key and how transactions read a consistent snapshot.
When two transactions read and write the same key at the same time, one of them must wait or abort. In a system that stores only the current value of each key, every read competes with every write. Reads block writes, writes block reads, and throughput drops under concurrency.
Multi-Version Concurrency Control (MVCC) solves this problem. Instead of storing one value per key, the system stores multiple versions. Each write creates a new version. Reads select the version that was current at the time the read started. Reads never block writes, and writes never block reads.
This chapter explains how Kahuna implements MVCC: the version chain, write intents, snapshot reads, the snapshot floor, revision pruning, and the disk fallback for deep history.
Each key in Kahuna can have multiple versions. The
KeyValueEntry stores the current version and an archive of
older versions.
The KeyValueEntry is a sealed class that holds the
complete state of a key:
| Field | Type | Description |
|---|---|---|
Value |
byte[]? |
The current value. |
Revision |
long |
The current modification revision (incremented on each write). |
LastModified |
HLCTimestamp |
The HLC timestamp of the most recent write. |
Expires |
HLCTimestamp |
The TTL expiry timestamp. Zero means no expiry. |
State |
KeyValueState |
Set, Deleted, or Undefined. |
WriteIntent |
KeyValueWriteIntent? |
Uncommitted transaction write (null if no active transaction). |
Revisions |
KeyValueRevisionHistory? |
Archive of older versions. |
MvccEntries |
Dictionary<HLCTimestamp, KeyValueMvccEntry>? |
Per-transaction snapshots for read-your-writes. |
FloorBoundaryRevision |
long |
The revision pinned by the snapshot floor (default -1). |
FloorBoundaryCoverageEnd |
HLCTimestamp |
Upper bound where the floor-boundary revision is authoritative. |
FlushedRevision |
long |
Highest revision confirmed on disk. The entry is dirty while
Revision > FlushedRevision. |
LastUsed |
HLCTimestamp |
Last access time (for LRU eviction). |
LruPrev, LruNext |
KeyValueEntry? |
Intrusive doubly-linked list pointers for LRU cache management. |
The entry acts as the head of a version chain. The current version is
in the Value and Revision fields. Older
versions are in the Revisions archive.
The KeyValueRevisionHistory stores older versions of the
key as a compact sorted array of
(long Key, KeyValueRevisionEntry Value) pairs in ascending
revision order. Each KeyValueRevisionEntry is a readonly
record struct with four fields: Value,
LastModified, Expires, and
State.
The array is bounded. At most RevisionRetention + 1
entries are stored in memory: the configured retention count (default
16) plus an optional floor-boundary entry. Initial capacity is 1. The
array grows by doubling. It never shrinks.
Lookups use binary search. The
TryGetRevisionAtOrBefore(snapshot) method finds the highest
revision whose LastModified is at or before the given
snapshot timestamp. This is the core operation for snapshot reads.
The sorted-array structure replaces a dictionary. For the small bound (at most 17 entries), a sorted array is more memory-efficient than a hash table.
A write intent is an uncommitted transaction write. When a
transaction calls SetKeyValue within a session, the system
does not immediately apply the write to the committed state. Instead, it
attaches a KeyValueWriteIntent to the entry.
The KeyValueWriteIntent is a sealed class with these
fields:
| Field | Type | Description |
|---|---|---|
TransactionId |
HLCTimestamp |
The ID of the transaction that created this intent. |
Expires |
HLCTimestamp |
The lease deadline. Zero means session-owned (no timeout). |
CommitTimestamp |
HLCTimestamp |
Set at prepare time. Zero before prepare. |
RecordAnchorKey |
string? |
Set at prepare. Points to the transaction record. |
A write intent is live if its lease has not expired. Session-owned intents (with zero expiry) are live as long as the session is active.
Create. A transaction writes to a key. The
system creates a KeyValueWriteIntent and attaches it to the
entry. The entry’s committed state does not change.
Visibility. The intent is visible only to its
own transaction. Other transactions that read the key see the committed
state, not the intent. If another transaction encounters a live write
intent from a different transaction, it may receive
WaitingForReplication (the safe-time wait mechanism) or
detect a conflict.
Prepare. At 2PC phase 1, the intent receives a
CommitTimestamp and a RecordAnchorKey. The
system creates a durable prepared intent in the
PreparedIntentStore.
Commit. At 2PC phase 2, the intent’s value becomes the new committed state. The revision increments. The old committed value moves to the revision archive. The intent is cleared.
Rollback. If the transaction aborts, the intent is removed. The committed state is unchanged.
When a TryGet handler encounters a write intent from
another transaction:
CommitTimestamp falls
at or before the snapshot. If so, the read returns
WaitingForReplication (the system must wait for the intent
to resolve before it can serve a consistent read).When a TrySet handler encounters a live write intent
from another transaction, the write is rejected. Only one transaction
can hold a write intent on a key at a time.
When a transaction reads a key for the first time, the system
snapshots the committed state into an MvccEntries
dictionary, keyed by transaction ID. The snapshot is a
KeyValueMvccEntry with the value, revision, timestamps, and
state at the time of the read.
On subsequent reads within the same transaction, the system returns the snapshot. This provides read-your-writes consistency: a transaction always sees its own writes and the state it read at the start.
If the committed revision advances past the snapshot (another
transaction committed between the reads), the handler returns
Aborted. The transaction’s view is stale and must be
retried.
This mechanism is the foundation of snapshot isolation. Each transaction reads from a consistent snapshot. Writes from other transactions that commit after the snapshot are invisible.
A snapshot read retrieves the value of a key as of a specific HLC
timestamp. The client specifies the timestamp through the
snapshotMs parameter on GetKeyValue.
The TryGetHandler resolves a snapshot read in two
steps:
Check the current version. If the entry’s
LastModified is at or before the snapshot timestamp, the
current version is the answer.
Search the archive. If the current version is
newer than the snapshot, the handler calls
entry.TryGetRevisionAtOrBefore(readTimestamp). This
binary-searches the in-memory revision archive for the highest revision
whose LastModified is at or before the snapshot.
Disk fallback. If the in-memory archive does not
contain the version (the archive was pruned, or the snapshot is older
than the oldest retained revision), the handler calls
PersistenceBackend.GetKeyValueRevisionAtOrBefore(key, maxRevision, readTimestamp).
This queries the persistence backend (RocksDB or SQLite) for the
version.
If no version exists at or before the snapshot timestamp, the read
returns DoesNotExist.
The in-memory archive holds at most RevisionRetention
versions (default 16). For keys with high write frequency, older
versions are pruned from memory. These versions may still exist on disk
if persistent revision retention is enabled.
The ResumeRead mechanism handles asynchronous disk
fallback for cache misses on the current value (non-snapshot reads):
ResumeRead control message back to the actor.ResumeRead is a control message, so it bypasses the
inbox bound and is processed promptly.
The snapshot floor is the minimum HLC timestamp below which revisions may be pruned. It protects long-running snapshot reads and transactions from losing the versions they depend on.
The SnapshotFloorStore is a replicated hold registry on
the meta partition (partition 0). It tracks active snapshot holds and
computes the effective floor.
A snapshot hold is a record with four fields:
| Field | Type | Description |
|---|---|---|
HoldId |
identifier | Unique ID for this hold. |
HolderId |
identifier | The entity that acquired the hold (transaction, client session). |
Timestamp |
HLCTimestamp |
The snapshot point to protect. |
LeaseExpiry |
HLCTimestamp |
Deadline for the hold. The hold expires if not renewed. |
AcquireAsync(holderId, timestamp, leaseMs): creates a
hold. Idempotent by (holderId, timestamp): a duplicate
acquire returns the existing hold. The operation replicates through Raft
on the meta partition.RenewAsync(holdId, leaseMs): extends the lease
deadline. Returns DoesNotExist if the hold expired.ReleaseAsync(holdId): removes the hold. The floor rises
when the lowest hold is released.PurgeExpiredHoldsAsync(): periodic cleanup of holds
whose lease has lapsed.GetEffectiveFloor(currentTime) computes the minimum
timestamp among all live holds. This is the effective snapshot floor. A
revision whose LastModified is below the floor is a
candidate for pruning.
The method has an O(1) fast path when the cache is valid and an O(N)
slow path when a hold may have expired. Prune operations and acquire
operations are ordered through a pruneCommitLock to prevent
races.
Two metrics track the floor state:
kahuna.snapshot_floor.live_holds: the number of active
holds.kahuna.snapshot_floor.effective_floor_ms: the effective
floor timestamp in milliseconds.The revision archive grows with each write. Without pruning, memory consumption grows without bound. Kahuna prunes old revisions based on three criteria.
The RemoveExpiredRevisions method in
BaseHandler runs after each write. It computes a cutoff
revision:
cutoff = currentRevision - RevisionRetention + 1
Revisions below the cutoff are candidates for removal. The method removes all revisions below the cutoff with one exception: the floor-boundary revision.
If snapshot holds exist, the pruning logic pins the highest
below-cutoff revision whose LastModified is at or before
the effective snapshot floor. This revision is the floor-boundary
revision. It stays in the archive even though it is below the retention
cutoff.
The floor-boundary revision is the safety net for snapshot reads. A snapshot read at a timestamp near the floor can use this revision as its answer. Without it, the read would need to fall back to disk.
When revisions between the floor-boundary revision and the retention
window are trimmed, the entry records
FloorBoundaryCoverageEnd: the smallest
LastModified among the trimmed revisions. This value marks
the point where the floor-boundary revision stops being
authoritative.
When TryGetRevisionAtOrBefore finds the floor-boundary
revision as the best match, it checks whether the snapshot timestamp is
below FloorBoundaryCoverageEnd. If the snapshot is at or
above that boundary, a gap exists: there may be a trimmed revision on
disk that is a better match. The method returns a miss, and the handler
falls back to disk.
On disk, revisions are subject to separate retention controls:
| Flag | Default | Description |
|---|---|---|
--persistent-revision-retention-count |
0 (keep forever) | Maximum persisted revisions per key. |
--persistent-revision-retention-age |
0 (disabled) | Maximum age in seconds for persisted revisions. |
--persistent-revision-cleanup-interval |
300s | Minimum interval between cleanup sweeps. |
--persistent-revision-cleanup-batch-size |
1000 | Maximum revision records deleted per sweep. |
When both in-memory and persistent retention are configured, the
system retains the most recent RevisionRetention versions
in memory and up to persistent-revision-retention-count
versions on disk. Older versions are deleted during cleanup sweeps.
The safe timestamp is the minimum prepared
CommitTimestamp across all live write intents in a shard.
Any snapshot timestamp strictly below this value avoids cutting across a
currently prepared transaction.
The GetSafeTimestampHandler scans both the B-tree store
and the prefix locks to find this minimum. The safe timestamp is used by
the snapshot coordinator to choose a consistent point for snapshot
reads.
If no write intents are active, there is no lower bound, and any timestamp is safe.
The PreparedIntentStore is a partition-scoped authority
for durable prepared intents. At most one live intent can exist per key.
The store uses a ConcurrentDictionary for concurrent reads
during visibility lookups, with mutations serialized by a lock.
When a TryGetHandler encounters a durable prepared
intent from another transaction, it calls
DurableReadVisibility.Resolve to determine how to
proceed:
MustRetry and let the client retry after the intent
resolves.This mechanism prevents a reader from seeing an inconsistent state when a transaction is in the middle of the two-phase commit protocol.
DoesNotExist or stale data.Prevention: renew the snapshot hold before it expires. Set the lease duration longer than the expected transaction duration.
FloorBoundaryCoverageEnd is set to T5.FloorBoundaryCoverageEnd
(T5). The handler returns a miss.The FloorBoundaryCoverageEnd check prevents the
in-memory archive from returning an incorrect answer when revisions were
trimmed.
Write intents have lease deadlines. Expired intents are cleaned up on the next access. The committed state is always authoritative.
Kahuna uses MVCC to allow reads without blocking writes. Each key stores multiple versions: a current version and an archive of up to 16 older versions in memory.
Write intents represent uncommitted transaction writes. They are visible only to their own transaction. Other transactions see the committed state. The intent lifecycle follows the 2PC protocol: create, prepare (with commit timestamp), commit (becomes the new version), or rollback (removed).
Snapshot reads resolve the version that was current at a given HLC timestamp. The resolution searches the in-memory archive first and falls back to disk for deep history.
The snapshot floor protects long-running reads from revision pruning. Snapshot holds lease a timestamp. While a hold is active, revisions at or above that timestamp are retained. The floor-boundary revision is pinned below the retention cutoff as a safety net.
Revision pruning bounds memory consumption. In-memory pruning runs
after each write and keeps the most recent
RevisionRetention versions. Persistent retention is
controlled by separate flags for count and age.
The next chapter examines transaction internals: how sessions, 2PC coordination, write-intent resolution, and conflict detection work at the implementation level.
Chapter 6 introduced transactions from the application’s perspective: script transactions for server-side logic and interactive sessions for client-driven workflows. Chapter 19 explained how MVCC stores multiple versions and how write intents represent uncommitted writes.
This chapter moves inside the transaction subsystem. It describes the two-phase commit (2PC) protocol as Kahuna implements it, durable intents, deferred settlement, conflict detection, admission control, session reaping, and transaction recovery.
Every interactive transaction starts with a session. The
TransactionCoordinator manages sessions in a
ConcurrentDictionary<HLCTimestamp, TransactionContext>,
keyed by transaction ID.
StartTransaction performs these steps:
MaxTransactionTimeout.TransactionPriorityOrderer. If no slot is available and the
admission wait expires, return AdmissionRefused.TransactionContext and add it to the sessions
dictionary.The transaction ID is an HLC timestamp. It provides a globally unique identifier that also carries a causal ordering.
Each operation within a session (a read, write, lock acquisition) is
tracked through BeginOperation /
CompleteOperation / CancelOperation. The
coordinator folds each operation’s effects into the session’s working
set: which keys were read, which were modified, and which locks were
acquired.
This working set is the input to the commit protocol. It tells the coordinator which partitions are involved and what needs to be validated.
The TransactionPriorityOrderer is a per-node admission
gate that limits the number of concurrent transactions. Two independent
orderer instances exist: one for interactive sessions and one for script
transactions.
When the transaction count is below the ceiling, admission is immediate. When the count is at the ceiling, the caller parks on an awaitable. Parked callers are served in priority order (four priority levels).
AdmitAsync returns an AdmissionLease. The
lease is IDisposable with exactly-once release through
Interlocked.Exchange. A leaked lease (the caller crashes
without disposing) permanently shrinks capacity. The session reaper
eventually cleans up the session and releases the lease.
The admission wait is clamped to MaxAdmissionWaitMs. If
the wait exceeds this value, the caller receives
AdmissionRefused.
When the client calls Commit, the coordinator enters the
2PC protocol. The protocol has four phases: prepare, validate, decide,
and resolve.
Prepare Install write intents and acquire locks on each partition.
↓
Validate Check the read set for conflicts (optimistic mode).
↓
Decide CAS transition on the canonical transaction record:
Undecided → Committed or Undecided → Aborted.
↓
Resolve Materialize committed values or discard aborted intents.
CommitTransaction begins with contention control. The
coordinator enters a finalize slot on the
TransactionContext through
context.EnterFinalize(). This slot serializes concurrent
commit, rollback, and reaper operations against the same session. If
another thread already holds the slot, the caller receives a
Mirror response (wait for the owner’s outcome) or
Rejected (MustRetry).
After acquiring the slot, the coordinator calls
FreezeForFinalize, which drains in-flight operations. No
new operations are accepted after the freeze. Then it calls
TwoPhaseCommit.
The TwoPhaseCommit method handles four cases based on
what the transaction modified:
Read-only transaction. No modified keys. The
coordinator validates the read set only (conflict probe plus revision
check). If any read key was modified by another transaction since the
read, the result is Aborted. No Raft proposals are
needed.
All-ephemeral transaction. All modified keys are
ephemeral (in-memory only). The coordinator validates the read set,
installs write intents via PrepareMutations, checks for
commit conflicts, and applies the writes via
CommitMutations. If any step fails, the writes are rolled
back. No durable transaction record is created.
All-persistent (durable) transaction. All modified
keys are persistent. The coordinator delegates to the
DurableTransactionFinalizer, which drives the full durable
2PC protocol with a canonical transaction record and prepared
intents.
Mixed transaction. Some modified keys are persistent and some are ephemeral. The coordinator prepares ephemeral keys first (so an ephemeral failure aborts before the durable decision). Then it runs the durable protocol for persistent keys. The durable decision drives the ephemeral commit or rollback.
The DurableTransactionFinalizer drives one transaction
through the durable 2PC model. It works with frozen, immutable inputs:
the transaction ID, epoch, coordinator key, commit timestamp, decision
deadline, a manifest of participant partitions, and per-partition
prepared intent sets.
Before anything durable, the finalizer checks each write intent’s
BaseRevision and BaseState against the current
committed state on the target partition. If the base moved (another
transaction committed to the same key), the transaction aborts with a
conflict. This check catches conflicts early, before any Raft
proposals.
The finalizer creates a canonical transaction record on the anchor
partition in the Undecided state. This is a Raft proposal.
The record contains the transaction ID, epoch, and manifest hash.
The anchor partition is determined by the transaction’s anchor key. The canonical record is the single source of truth for the transaction’s outcome.
The finalizer installs prepared intents on each participant partition
through Raft. Each prepared intent records the transaction ID, the key,
the proposed value, the commit timestamp, and a pointer to the canonical
record (the RecordAnchorKey).
If a prepare is blocked by a foreign intent from a different
transaction that already has a terminal decision (committed or aborted
but not yet settled), the finalizer helps settle that intent before
retrying. This is called “prepare-conflict helping”: the
resolveDecidedBlockers mechanism settles the blocking
intent and retries the prepare. The finalizer retries up to
MaxPrepareRetries (8 attempts).
The finalizer never touches undecided foreign records. An undecided record belongs to a live transaction. Interfering with it would violate that transaction’s isolation.
For optimistic transactions with
ReadValidation.TrackAndValidate, the coordinator re-reads
the current committed revision for each key in the read set. If any
revision advanced since the snapshot, the transaction aborts.
The finalizer performs a compare-and-swap transition on the canonical record:
Undecided → Committed. The
commit is allowed only if
AttemptHlc <= DecisionDeadline. A late commit attempt is
rejected. If the deadline passed, recovery presumes abort.Undecided → Aborted or
absent → Aborted. Abort can create a tombstone from absence
(presumed abort).The TransactionRecordStateMachine enforces these
transitions deterministically:
| Current State | Command | Result |
|---|---|---|
| absent | Initialize | Undecided |
| Undecided | Commit (within deadline) | Committed |
| absent or Undecided | Abort | Aborted |
| Committed | Commit (same) | Idempotent (no change) |
| Aborted | Abort (same) | Idempotent (no change) |
| Committed | Abort | Rejected (terminal is final) |
| Aborted | Commit | Rejected (terminal is final) |
| absent | Commit | Rejected (needs Undecided proof) |
Once the record reaches Committed or
Aborted, it is terminal. No further transitions are
possible.
After the decision is durable, the finalizer resolves the prepared intents:
Resolution can be synchronous or deferred (asynchronous), based on
the DurableDeferredSettlement configuration.
For single-partition transactions, the finalizer bundles the record
initialization, anchor prepare, and commit decision into one atomic Raft
proposal. This reduces three Raft round trips to one. If the prepare
fails (a bundledPrepareProbe gate rejects it), the bundle
is rejected and the transaction falls back to the multi-step path.
For multi-partition transactions, the finalizer bundles the record initialization and the anchor partition’s prepare into one Raft proposal. This saves one pre-decision Raft round trip compared to separate proposals.
Deferred settlement is an optimization that moves intent resolution
off the commit critical path. The decision is durable (the canonical
record is committed in Raft). The resolution (materializing values,
settling intents) happens asynchronously through a
ResolutionScheduler.
The benefit: the commit returns to the client as soon as the decision is durable. The client does not wait for resolution.
If the background resolution is lost (the node crashes before resolution completes), the recovery sweep finds the committed record and resolves the intents. Correctness does not depend on the background run completing.
Deferred settlement is enabled by default
(DurableDeferredSettlement = true). In practice, the system
runs resolution synchronously for cross-node transactions to ensure
read-your-writes consistency: a read immediately after a commit must see
the committed value, which requires the intent to be resolved before the
commit returns.
Kahuna uses two conflict detection mechanisms, one for each locking mode.
In optimistic mode, reads do not acquire locks. Conflicts are detected at commit time.
ValidateReadSet checks two conditions:
Read observation conflict. If the same key was observed at two different base revisions within one transaction (the key changed between two reads in the same session), the transaction aborts.
Concurrent modification. For each read key that is not in the modified set, the coordinator re-reads the current committed revision. If the revision changed since the snapshot, another transaction committed to that key. This transaction aborts.
CheckCommitConflicts probes for concurrent write
intents. If a foreign write intent appeared after this transaction’s
read, a write-skew may exist. The transaction aborts.
In pessimistic mode, the transaction acquires exclusive locks on every key it reads or writes. Other transactions that touch the same keys wait (up to the transaction timeout) or abort.
Lock acquisition happens before the operation, not at commit time.
Conflicts are detected immediately: if the lock is held by another
transaction, the operation returns AlreadyLocked.
Pessimistic mode eliminates read-set validation overhead. There are no concurrent modifications to detect because locks prevent them. The trade-off is lower concurrency: transactions wait for each other instead of running in parallel and detecting conflicts at commit.
The ScriptTransactionExecutor handles script-driven
transactions. It composes the TransactionCoordinator for
all 2PC and lock-release operations.
TryExecuteTx parses the script and decides whether a
transaction is needed. A single standalone command (one SET
or GET with no BEGIN/COMMIT) runs
directly without the admission gate. Multi-statement scripts or explicit
BEGIN blocks open a transaction through the
coordinator.
Script transactions use a separate admission orderer from interactive sessions. This prevents a burst of script transactions from starving interactive sessions (or the reverse).
An extra locking delay of 10 ms (ExtraLockingDelay) is
added to pessimistic lock TTLs in script transactions. This accounts for
the parsing and execution overhead.
The TransactionRecordStore is the partition-scoped
authority for canonical transaction records. Records are keyed by
(TransactionId, Epoch).
Mutations go through Apply(TransactionRecordCommand),
which is serialized by a lock. The
TransactionRecordStateMachine evaluates the command against
the current state and produces the transition (or a rejection).
The store is replicated through Raft. Restore and
Replicate methods apply write-ahead log entries. The store
persists per-partition snapshots to disk (temp file plus rename).
Snapshots are loaded on startup. A version counter avoids unnecessary
snapshot rewrites when no records changed.
The CompletionReceiptStore is a node-local store of
completion receipts. A receipt records that a participant partition
durably committed the value for a specific transaction and key.
Receipts serve two purposes:
Leader-change resilience. After a leader change,
the new leader’s actors have empty in-memory state. If a re-delivered
commit arrives for a transaction that already committed, the receipt
lets the server answer Committed instead of ambiguous
MustRetry.
Range split/merge gating. Before a range split
or merge cutover, SettleSuppliedIntentsAsync must resolve
all durable intents in the moving range. Completion receipts are
transferred to the new partition so it can serve re-commits correctly
after cutover.
Receipts are removed by coordinator acknowledgement
(Forget) or by an age-based backstop
(CollectExpired). They are not evicted by size.
Abandoned sessions (the client crashed, disconnected, or forgot to commit/rollback) are cleaned up by the session reaper.
ReapAbandonedSessions runs periodically on each
collection sweep. For each session, it computes a deadline:
deadline = transactionId + Timeout + ReapGraceMs (15 seconds)
If the session has pending operations, the deadline is extended by
MaxParticipantEffectTtlMs (15 seconds) to allow in-flight
operations to complete.
When a session passes its deadline:
TryEnterReap().ReapSession sets the action to Abort and
releases the working set.RolledBack is retained for duplicate
handling.Errored. The session is removed.MustRetry. The
session stays in the Reaping state. The next sweep
retries.The reaper also prunes retained terminal outcomes by age on each sweep.
The DurableTransactionRecovery handles orphaned prepared
intents. A prepared intent can become orphaned when the coordinator
crashes after preparing but before deciding, or when a leader change
occurs between prepare and resolve.
SweepAsync runs periodically on each partition leader.
It groups unresolved prepared intents by
(TransactionId, Epoch, RecordAnchorKey). For each group, it
looks up the canonical transaction record on the anchor partition.
The sweep produces one of four outcomes:
| Record State | Action |
|---|---|
| Committed | Materialize values and settle intents. |
| Aborted | Discard intents and settle. |
| Undecided, past deadline | Drive a presumed-abort: CAS Undecided → Aborted at the
anchor. A concurrent commit can still win the CAS. Return whatever the
record became. |
| Undecided, within deadline | Skip. The transaction is still live. |
| No record at all | Orphan prepare. Drive a presumed-abort: CAS
absent → Aborted. |
Kahuna uses the presumed-abort rule: any transaction without a
committed decision record is aborted. If the coordinator crashes before
deciding, the recovery sweep finds no record (or an
Undecided record past its deadline) and aborts the
transaction.
This is safe because the commit decision requires a durable
Committed record. If no such record exists, the transaction
did not commit. The prepared intents are discarded.
When a prepare is blocked by a foreign intent whose record is already
terminal (committed or aborted but not yet settled), the recovery
mechanism settles the blocking intent immediately. This is
TryResolveDecidedBlockersAsync. It never touches undecided
records (those belong to live transactions).
This mechanism prevents a resolved-but-unsettled intent from blocking new transactions indefinitely.
Every transaction terminates with one of these outcomes:
| Outcome | Meaning | Action |
|---|---|---|
Committed |
All writes applied. | Success. |
Aborted |
Conflict or validation failure. All writes rolled back. | Retry with a new session. |
RolledBack |
Explicit rollback completed. | Application logic decides next step. |
MustRetry |
Transient failure (drain timeout, unresolved durable decision). Session may still be live. | Retry. |
Errored |
Permanent unknown outcome (session expired, never existed). | Log and alert. |
The coordinator retains terminal outcomes in a window after session
removal. If a duplicate commit or rollback arrives for a session that
already terminated, the coordinator returns the retained outcome instead
of MustRetry.
Committed (it already has
enough prepares).Committed. The sweep materializes the
value on P2.The decision is durable. Resolution eventually reaches all participants, even after crashes.
MustRetry.RetryableTransaction, the entire
transaction retries with a fresh session on the new leader.transactionId + Timeout + ReapGraceMs.RolledBack is retained briefly for
duplicate handling.RetryableTransaction retries Transaction A with a fresh
session. The retry reads revision 6.Kahuna’s transaction subsystem orchestrates 2PC through the
TransactionCoordinator (session management) and the
DurableTransactionFinalizer (durable 2PC protocol).
The protocol has four phases: prepare (install intents on participant partitions), validate (check the read set for conflicts), decide (CAS transition on the canonical record), and resolve (materialize or discard intents). Single-partition transactions use a one-phase fast path that bundles all three durable steps into one Raft proposal.
Conflict detection uses read-set validation for optimistic
transactions and lock acquisition for pessimistic transactions. The
TransactionRecordStateMachine enforces deterministic CAS
transitions on the canonical record.
Recovery uses the presumed-abort rule: any transaction without a committed decision record is aborted. The recovery sweep resolves orphaned intents and settles decided-but-unresolved blocking intents.
Session reaping cleans up abandoned sessions after a grace period. Admission control limits concurrent transactions and serves waiters in priority order. Completion receipts provide leader-change resilience and gate range split/merge cutover.
The next chapter examines lock and sequencer internals: how the lock subsystem tracks ownership and fencing tokens, and how the sequencer allocates ID blocks.
Chapter 4 introduced distributed locks from the application
perspective: acquire, extend, release, and the fencing token that
protects against stale holders. Chapter 5 introduced the sequencer: a
monotonic counter that reserves values in blocks for throughput. Both
subsystems appeared again in Chapter 18, which described the
LockActor and SequenceActor at the actor-model
level.
This chapter moves inside both subsystems. It explains how fencing tokens stay monotonic across leader changes, how lock mutations flow through Raft, how the unflushed-writes overlay prevents read gaps, how the sequencer reserves blocks through compare-and-swap on a key-value entry, and how idempotent reserves work.
Each lock resource is represented by a LockEntry, a
sealed class with seven fields:
| Field | Type | Description |
|---|---|---|
Owner |
byte[]? |
The identity of the current holder. Null when unlocked. |
Expires |
HLCTimestamp |
The lease deadline. The lock is free after this time. |
FencingToken |
long |
Monotonically increasing token. Incremented on each acquisition. |
LastUsed |
HLCTimestamp |
Last access time (for cache eviction). |
LastModified |
HLCTimestamp |
HLC timestamp of the most recent mutation. |
State |
LockState |
Locked or Unlocked (default is
Locked). |
ReplicationIntent |
LockReplicationIntent? |
In-flight Raft proposal metadata (proposal ID and expiry). |
The FencingToken is the field that matters most. Every
time a client acquires a lock, the actor sets the new token to
entry.FencingToken + 1. The token only goes up. A client
that holds token 5 can verify that no other client received token 6
before the first client’s write reaches the downstream resource.
Lock operations follow the same Manager, Locator, Actor pattern as key-value operations. The pipeline has four stages.
The LockManager is the entry point. It creates two
consistent-hash actor rings:
LockDurability.Persistent. These locks survive
restarts.LockDurability.Ephemeral. These locks exist only in
memory.The manager also creates a BalancingActor router for
LockProposalActor instances. This router distributes Raft
proposals across proposal actors in round-robin order. Lock proposals
are independent of each other, so there is no need for key-based
routing.
The manager holds a DataPartitionRouter for partition
resolution, a LockLocator for leader routing, a
LockRestorer for log replay, a LockReplicator
for follower applies, and an UnflushedLockWritesIndex for
read consistency.
The LockLocator resolves which node owns a lock
resource. It calls dataPartitionRouter.Locate(resource) to
find the partition, then checks AmILeaderIfHosted to
determine whether this node leads that partition.
If this node is the leader, the locator dispatches the request to the
local LockActor ring. If another node is the leader, the
locator forwards the request through inter-node communication. If no
leader is available (an election is in progress), the locator returns
MustRetry.
The LockActor holds the in-memory lock table: a
Dictionary<string, LockEntry> keyed by resource name.
Chapter 18 described the actor’s structure. This section focuses on the
three lock operations.
TryLock. The actor processes an acquisition in these steps:
ReplicationIntent, reject
the request. A Raft proposal is already in flight for this
resource.entry.FencingToken + 1.TryExtendLock. The actor validates that the caller
is the current owner and holds the same fencing token. If both checks
pass, the actor updates the Expires field with the new
deadline. Extend does not increment the fencing token.
TryUnlock. The actor validates the owner. If the
owner matches, the actor sets State to
Unlocked and clears the Owner to null.
The LockProposalActor is a fire-and-forget actor
(IActor<LockProposalRequest>). It handles Raft
replication for a single lock operation.
Unlike key-value writes, lock mutations are not batched. Each lock
operation creates its own Raft log entry. The proposal actor serializes
the lock mutation into a LockMessage protobuf, then calls
raft.ReplicateLogs(partitionId, ReplicationTypes.Locks, ...)
to propose it.
On success, the proposal actor sends a CompleteProposal
message back to the LockActor. On failure (Raft rejected
the proposal), it sends a ReleaseProposal message. Both are
control messages that bypass the actor’s inbox bound.
When the LockActor creates a proposal for a persistent
lock, it follows this sequence:
LockReplicationIntent on the entry. The intent
records the proposal ID and the expiry deadline.proposals dictionary (keyed by
proposal ID).BalancingActor router, which
dispatches it to a LockProposalActor.ByPassReply = true). The
client waits on the proposal’s TaskCompletionSource.When CompleteProposal arrives:
ReplicationIntent on the entry
matches the proposal. If it does not match, another operation replaced
the intent. Discard.FencingToken,
Owner, Expires, LastUsed,
LastModified, State.ReplicationIntent.TaskCompletionSource with the committed
fencing token.When ReleaseProposal arrives, the actor clears the
ReplicationIntent and completes the
TaskCompletionSource with an error. The client can
retry.
The fencing token is the contract that makes distributed locks useful. A lock without a fencing token cannot prevent a slow client from writing stale data after its lease expired and another client acquired the lock. The fencing token gives the downstream resource a way to reject writes from outdated holders.
Monotonicity means that each acquisition produces a token strictly
greater than the previous one. Kahuna enforces this at the actor level:
entry.FencingToken + 1 is computed from the current entry
state. Because the actor processes messages sequentially, two concurrent
acquisitions cannot read the same token value.
The replicator and restorer send InvalidateOrApply
messages to the LockActor to update its cache with
committed mutations. The actor applies these updates through an advance
guard:
Apply only if:
incoming.FencingToken > entry.FencingToken
OR (incoming.FencingToken == entry.FencingToken
AND incoming.LastModified > entry.LastModified)
This guard prevents two problems:
The second condition (same token, newer LastModified)
handles extend and unlock operations. These operations reuse the
existing fencing token but carry a later HLC timestamp.
The LockReplicator processes committed Raft log entries
on followers. When a lock mutation commits in the Raft log, the
replicator deserializes the LockMessage and performs four
steps:
durabilityTracker.RegisterPending(partitionId, logId, DurabilityChannel.Flush).
This tells the durability tracker that this log entry is committed but
not yet flushed to disk.unflushedLockWrites.Record(...). This updates the in-memory
overlay so that a read can find the committed state before the
background writer flushes it.QueueStoreLock message to the
BackgroundWriterActor. The background writer will flush the
mutation to disk asynchronously.LockActor ring. The actor applies the update
through the advance guard.The replicator handles TryLock, TryUnlock,
and TryExtendLock with the same four-step pattern. For
TryUnlock, the InvalidateOrApply message
passes null as the owner. This clears the holder in the actor’s cache,
matching the behavior of CompleteProposal on the proposing
leader.
The LockRestorer replays committed Raft log entries
during a leader change. When a partition’s leadership moves to a new
node, the Raft layer replays all committed entries that the new leader
received from the old leader’s log.
The restorer follows the same four-step pattern as the replicator:
InvalidateOrApply to the actor ring.The restorer ensures that the new leader’s actors hold committed state that is at least as recent as the replicated log. Without restoration, a re-promoted leader could mint fencing tokens from an outdated entry.
The UnflushedLockWritesIndex solves a specific race
condition. Consider this sequence:
LockActor has an empty cache for this
resource.The overlay prevents step 5. Before reading from the persistence
backend, the LockActor checks the overlay. If the overlay
holds a newer mutation for this resource, the actor uses the overlay
value instead of the stale disk value.
The overlay is a
ConcurrentDictionary<string, UnflushedLockWrite>.
Each entry records the owner, fencing token, expiry, timestamps, and
state. The Record method keeps only the newest head per
resource, using the same ordering as the advance guard: fencing token
first, LastModified as the tiebreak.
The RemoveFlushed method prunes the overlay after a
confirmed flush. It removes the entry only if the flushed mutation is at
least as new as the overlay head. If a newer mutation was queued after
the flush started, the overlay entry stays until that newer mutation is
also flushed.
The LockActor runs periodic garbage collection. Every
500 operations (CollectThreshold), the actor checks its
memory budget. If the budget is exceeded, it sends itself a
Collect control message.
The collection handler evicts entries where
(currentTime - LastUsed) exceeds the cache entry TTL. It
removes at most CacheEntriesToRemove entries per sweep. The
eviction does not affect the durable state on disk. An evicted entry is
simply reloaded from the persistence backend on the next access.
The ProposalWaitTimeout (10 seconds) bounds how long a
proposal can stay in flight. If a proposal does not complete within this
window, the actor releases it and returns an error to the caller.
The sequencer allocates monotonic values. Each sequence is backed by
a durable record stored as a key-value entry under the reserved prefix
__kahuna:sequences:{name}. The record holds the high-water
mark: the highest value that any node reserved. Values above the
high-water mark have not been allocated.
The durable record is a SequenceState object with these
fields:
| Field | Type | Description |
|---|---|---|
Name |
string |
The sequence name. |
CurrentValue |
long |
The high-water mark (highest reserved value). |
InitialValue |
long |
The starting value. |
Increment |
long |
The step between values. |
MaxValue |
long? |
Optional upper bound. |
CreatedAt |
HLCTimestamp |
When the sequence was created. |
UpdatedAt |
HLCTimestamp |
When the record was last modified. |
Idempotency |
Dictionary<string, SequenceIdempotencyEntry> |
Replayable allocations keyed by reserve:{key}. |
The record is serialized by SequenceStateCodec and
stored as the value of a key-value entry. The sequencer reads and writes
this entry through the standard key-value pipeline, including Raft
replication. One Raft commit per block reservation (not per value) is
the core throughput optimization.
The sequencer follows the same Manager, Locator, Actor pattern as locks:
SequencerManager. The entry point. It creates a
consistent-hash ring of SequenceActor instances (at least
one, controlled by SequencerWorkers). It validates inputs
(name length, increment, idempotency key length), resolves the owning
node through the SequenceLocator, and dispatches to the
local actor ring.
SequenceLocator. Resolves which node owns a
sequence. The locator routes the sequence’s storage key
(__kahuna:sequences:{name}) through
DataPartitionRouter.Locate to find the partition. Read
operations use ConfirmLeadershipIfHosted (quorum-confirmed,
linearizable). Mutation operations use AmILeaderIfHosted
(local belief), because the CAS write that follows will fail on a
deposed leader.
The locator returns MustRetry when no leader is
available. If another node is the leader, the locator forwards the
request through inter-node communication.
SequenceActor. Holds the in-memory block allocation state. Described in detail below.
The SequenceActor holds a
Dictionary<string, SequenceBlock> of active blocks.
Each SequenceBlock tracks:
StorageKey: the key-value key
(__kahuna:sequences:{name}).State: one of Loaded,
Refresh, or Invalid.Revision: the key-value entry revision at which this
block was won.Current: the next value to serve from this block.Ceiling: the upper bound of the reserved window.LastUsed: LRU access counter.Verified: a Stopwatch timestamp for lease
validation.When a Reserve request arrives, the actor follows this
logic:
Load. If no block exists for this sequence, read
the durable record from the key-value subsystem through
SystemGetKeyValue. Create a block from the record’s
CurrentValue and set the ceiling to
CurrentValue + (blockSize * increment).
Lease check. If the block exists but the lease
expired (Stopwatch.GetElapsedTime exceeds
SequencerBlockLease, default 5 seconds), mark the block for
refresh. The refresh re-reads the durable record to verify that another
node did not advance the high-water mark.
Idempotency check. If the request carries an idempotency key, check the block’s idempotency dictionary. If the key matches a recorded allocation, return the recorded result without touching storage.
Plan from block. If the block has room
(Current + count * increment <= Ceiling), compute the
allocation from memory. No Raft proposal is needed.
Bump the ceiling. If the block is exhausted or
does not have room, compute a new ceiling and write it to the durable
record through a CAS operation (SetIfEqualToRevision). If
the CAS succeeds, extend the block. If the CAS fails (another node won
the race), refresh the block and retry.
Retry. The actor retries failed CAS operations with decorrelated jitter backoff. The retry policy allows up to 16 attempts with a median first delay of 1 millisecond.
Step 4 is the fast path. When the block has remaining capacity, the
actor returns the next value purely from memory. No disk access, no Raft
proposal, no network round trip. This is why the sequencer achieves high
throughput: a block of 1,000 values (the default
SequencerBlockSize) costs one Raft commit, and the
remaining 999 values are served from local state.
The sequencer supports idempotent reserves. A client can pass an idempotency key with each reserve request. If the actor already allocated values for that key, it returns the same allocation without consuming new values.
Idempotency entries are stored in the durable record’s
Idempotency dictionary, keyed by
reserve:{key}. Each entry is a
SequenceIdempotencyEntry with the
SequenceAllocation and a CreatedAt
timestamp.
The retention window is bounded by two parameters:
| Parameter | Default | Description |
|---|---|---|
SequencerIdempotencyRetentionMax |
256 | Maximum entries per sequence record. |
SequencerIdempotencyRetentionTtl |
600s | Maximum age for an entry. |
When the actor persists a record,
SequenceStateCodec.Prune removes entries that exceed either
bound. Oldest entries are removed first. After pruning, a replayed
idempotency key that was evicted is treated as a new allocation.
A block is a reservation against the durable record. The reservation
is valid as long as no other node advanced the high-water mark past this
block’s ceiling. The block lease (SequencerBlockLease,
default 5 seconds) is the interval after which the actor must
revalidate.
The lease is tracked by a Stopwatch timestamp
(Verified field), not by an HLC clock. The
Stopwatch is monotonic and local, which avoids clock-skew
issues with remote timestamps.
When the lease expires:
Refresh.SystemGetKeyValue.Adopt method compares the durable record against
the local block. If the durable record’s CreatedAt,
InitialValue, or Increment changed, the
sequence was deleted and recreated with different parameters. The actor
voids the reserved window: all unissued values in the old block are
abandoned.Current and Ceiling from the durable
record.The Adopt method detects whether the durable record
represents the same sequence the block was reserved from. If a sequence
is deleted and recreated with different parameters, the new record has a
different CreatedAt or InitialValue or
Increment. The actor recognizes the change and discards the
stale block.
Without incarnation detection, the actor could serve values from a block that was reserved against a deleted sequence. The values would be correct numerically but would belong to the wrong logical sequence.
When the number of active blocks exceeds
SequencerMaxSequencesPerActor (default 10,000), the actor
evicts the least recently used blocks. The Admit method
checks the count before each load. If the count exceeds the limit, it
evicts the oldest 10% by LastUsed timestamp.
Eviction discards the in-memory block. Unissued values in the evicted block become gaps in the sequence. The next request for that sequence reloads the durable record and reserves a fresh block.
When a partition’s leadership moves to a new node, the
SequencerManager.OnLeaderChanged method invalidates all
blocks tied to that partition. The method sends an
Invalidate request to every SequenceActor
instance.
The invalidation is scoped by partition. Blocks on partitions that this node still leads are not affected. This prevents an unrelated election from discarding blocks for healthy partitions.
Invalidated blocks become gaps. The abandoned values (those reserved but not yet served) are never issued. The next request for the sequence reads the durable high-water mark and reserves a new block from there.
LockActor has no cached entry for R. It loads
from the persistence backend.4 + 1 = 5.Prevention. The unflushed-writes overlay (step 2 in
the replicator and restorer) holds the committed mutation. When node B
loads the entry from disk, it checks the overlay first. The overlay
shows fencing token 5. The actor computes 5 + 1 = 6.
Monotonicity is preserved.
This failure mode was found by Jepsen testing. The fix (in Kommander 1.0.10) addressed a WAL drain race that could cause the overlay to miss a committed entry during a narrow window around leadership transitions.
The partition move includes a state transfer that copies key-value entries. Lock entries are also transferred through the Raft log replay (the restorer). If the replay completes before D accepts lock requests, the token is correct. If a gap exists, the token may regress.
This is an open finding from Jepsen testing. The risk window is the interval between the moment D begins accepting requests and the moment the full log replay completes.
LockActor has no entry for the lock. The lock
is gone.Ephemeral locks are in-memory only. They are not replicated and cannot survive a leader change. Persistent locks survive because the Raft log records every mutation. Ephemeral locks are faster (no Raft proposal) but have weaker guarantees.
For persistent locks, the in-memory state (write intents, proposal
metadata) is also lost on leader change. But the committed state in the
Raft log is authoritative. Operations in progress at the time of the
leader change receive MustRetry. The client retries on the
new leader.
Point-in-time recovery (PITR) restores a cluster from a snapshot of
the key-value state. Lock state is stored separately from the key-value
state: locks have their own log entry type
(ReplicationTypes.Locks) and their own persistence
path.
A PITR restore does not include lock state. After a restore:
Applications that depend on fencing token continuity must account for this. A PITR restore is a discontinuity in the fencing token sequence.
Refresh.CurrentValue = 1100 (the
high-water mark from node A’s reservation).OnLeaderChanged.Gaps do not violate the monotonicity guarantee. The sequencer guarantees that each value is served at most once and that values are monotonically increasing within a single actor. It does not guarantee that every value in the range is served. The block model trades density for throughput.
Locks and sequences are simpler than transactions, but both subsystems have important correctness invariants.
The lock subsystem guarantees fencing token monotonicity through
three mechanisms: sequential actor processing
(FencingToken + 1), the advance guard in
InvalidateOrApply (which rejects stale or duplicate
replication messages), and the unflushed-writes overlay (which bridges
the gap between Raft commit and disk flush). Each lock mutation creates
its own Raft log entry through the LockProposalActor.
The sequencer achieves high throughput through block-based allocation. A block of 1,000 values costs one Raft commit. The remaining values are served from actor memory with no disk or network access. Block leases (5 seconds) force periodic revalidation against the durable record. Idempotency keys allow clients to replay reserve requests without consuming new values. The retention window bounds the idempotency dictionary to 256 entries or 10 minutes.
Both subsystems follow the Manager, Locator, Actor pipeline. Leadership changes invalidate in-memory state: the lock restorer replays committed entries to rebuild the actor cache, and the sequencer manager invalidates blocks for the affected partition. Ephemeral locks have no Raft log and cannot survive a leader change.
The next chapter examines the persistence layer: how Kahuna writes committed state to disk through the background writer, how the three storage backends (Memory, SQLite, RocksDB) differ, and how the IO scheduler manages read and write throughput.
Chapter 15 traced a write request from the client through the actor layer and into Raft. Chapter 17 explained how Raft replicates log entries across nodes. Neither chapter covered what happens after a log entry is committed: the committed state must reach durable storage so that a node can recover without replaying the full log. This chapter explains that final step.
Kahuna separates the Raft commit frontier from the disk-flush frontier. The Raft log is the source of truth for durability: once a majority of nodes confirm a log entry, the entry is committed. The background writer then moves committed state to a storage backend asynchronously. The gap between the two frontiers creates a window where a read might miss recently committed data. The unflushed overlay closes that gap by merging queued writes into every read path. This chapter covers the persistence interface, the three storage backends, the background writer, the IO scheduler, the cache eviction cycle, and the durability tracker.
The IPersistenceBackend interface defines the contract
that every storage backend must satisfy. It contains approximately 20
methods that fall into five categories.
Write methods store committed state:
StoreLocks persists a batch of lock entries.StoreKeyValues persists a batch of key-value entries,
including revision history rows and no-revision provenance markers.StoreDurabilityFloors persists per-partition watermarks
that track the highest durable log index.Point-read methods retrieve individual entries:
GetLock returns a single lock by resource name.GetKeyValue returns the current version of a key.GetKeyValues returns multiple keys in a single batched
call (MultiGet on RocksDB).GetKeyValueRevision returns a specific revision of a
key.GetKeyValueRevisionAtOrBefore returns the newest
revision at or before a given ceiling, used for MVCC snapshot
reads.Scan methods support prefix and range queries:
GetKeyValueByPrefix returns all current-version entries
whose key starts with a given prefix.GetKeyValueByRange returns current-version entries
within a key range, with a limit.ScanKeyValues and ScanLocks perform
cursor-based paged scans across the full store.Maintenance methods manage storage lifecycle:
DeleteKeyValues and DeleteLocks remove
entries permanently.PruneKeyValueRevisions deletes old revision history
rows that fall outside the retention window.GetPrunedHistoryFloor returns the oldest surviving
revision timestamp after pruning.Checkpoint methods produce consistent snapshots:
CreateCheckpoint captures the current state for Raft
state transfer.CreateCheckpointAsOf captures state as of a specific
HLC timestamp for point-in-time recovery. The
SupportsExactAsOfCheckpoint property indicates whether the
backend can produce an exact as-of image.Every backend implements this full interface. The choice of backend affects performance characteristics, memory usage, and recovery behavior, but the semantics remain the same.
MemoryPersistenceBackend stores all data in process
memory. It is designed for tests and for ephemeral deployments where
durability across restarts is not required.
The backend uses three data structures:
SortedList<string, KeyValueEntry> for current
key-value data. The sorted list is array-backed, so prefix and range
lookups use binary search through a LowerBound method
(standard binary search that returns the first index at or above the
target).ConcurrentDictionary<string, LockEntry> for
lock data.ConcurrentDictionary<string, ConcurrentDictionary<long, KeyValueEntry>>
for revision history. Each revision stores an independent snapshot of
the entry, not a reference to the current-version entry.The memory backend tracks no-revision provenance in a separate
dictionary that records the earliest and latest HLC timestamps of writes
that carried no explicit revision. This information is necessary for
as-of checkpoint safety: if a no-revision write in the boundary window
was overwritten and cannot be reconstructed,
CreateCheckpointAsOf fails closed with
ExactCheckpointUnavailableException.
Checkpoints serialize the in-memory state to JSON files
(store.json and locks.json) in a temporary
directory, then atomically rename the directory into place. The
OpenCheckpoint static factory deserializes the JSON back
into a fresh backend instance.
The PruneKeyValueRevisions method is a no-op on the
memory backend. It returns zero deleted revisions because the memory
backend retains everything for the lifetime of the process.
SqlitePersistenceBackend stores data in SQLite databases
on disk. It provides durability without external dependencies and works
well for single-node deployments or moderate workloads.
The backend distributes data across up to 8 SQLite databases (the
constant MaxShards). Keys are assigned to shards through
HashUtils.InversePrefixedHash: the method extracts the
prefix before the last / separator and hashes it, so all
keys that share a prefix land in the same shard. Prefix scans use
HashUtils.ConsistentHash at one level lower to ensure the
scan targets a single shard.
Each shard has its own SqliteConnection and its own
ReaderWriterLock. Write operations acquire the writer lock,
begin a transaction, prepare a statement once, bind and execute per row,
and commit. Read operations acquire the reader lock with a 5-second
timeout.
Each shard database contains six tables:
locks: resource as primary key, owner as BLOB, HLC
triplets for expires, lastUsed, and lastModified, fencing token, and
state.keys: key as primary key, revision, value as BLOB, HLC
triplets, and state.keys_revisions: composite primary key of (key,
revision), same columns as keys. Indexed by
(key, revision DESC) and by
lastModifiedPhysical for efficient pruning sweeps.keys_norev: key as primary key, earliest and latest HLC
triplets for no-revision provenance tracking.pitr_meta: metadata for point-in-time recovery
markers.durability_floor: partition as integer primary key,
floor as integer. Always stored on shard 0.All connections use WAL journal mode,
synchronous=NORMAL, and temp_store=MEMORY.
Connection pooling is disabled (Pooling=False) to prevent
file descriptor leaks.
When a write carries no explicit revision, the backend must record
provenance so that as-of checkpoints can detect boundary conflicts. The
SQL upsert uses CASE expressions to compute the minimum of the earliest
HLC and the maximum of the latest HLC against the existing row, all
within a single INSERT...ON CONFLICT statement. The
comparison follows HLC order: physical timestamp first, then counter,
then node.
The backend tracks the oldest surviving revision timestamp after
pruning in the pitr_meta table under the key
pruned_history_floor. If the floor becomes corrupt or
unreadable, the backend sets a FailClosedFloor value of
(int.MaxValue, long.MaxValue, uint.MaxValue), which
prevents any as-of checkpoint from succeeding. This fail-closed behavior
ensures that a corrupted floor never allows an incomplete
checkpoint.
The backend also maintains resumable sweep cursors
(sweepShardCursor and sweepKeyCursor) so that
revision pruning can progress across multiple invocations without
restarting from the beginning.
RocksDbPersistenceBackend stores data in a RocksDB
database. It is the recommended backend for production deployments
because RocksDB provides efficient compaction, bloom filters, and a
shared block cache.
The backend creates two column families:
kv for key-value data: 64 MB write buffer, 3 write
buffers maximum, merge after 1 buffer fills.locks for lock data: 8 MB write buffer, 2 write buffers
maximum, merge after 1 buffer fills.A static LRU block cache of 256 MB is the fallback when no shared resources are provided. Each column family uses 10-bit bloom filters with full-key filtering. Index and filter blocks are cached, and L0 filter and index blocks are pinned in memory.
Database-level options include: create-if-missing,
AbsoluteConsistency WAL recovery mode, parallelism set to
max(2, ProcessorCount), maximum 2 flush threads, and
maximum max(2, ProcessorCount / 2) compaction threads.
Write operations use SetSync(true) for an explicit fsync
after each WAL write. Maintenance scans use
SetFillCache(false) to prevent sweep reads from polluting
the block cache.
When the constructor receives a RocksDbSharedResources
object, the backend uses a shared block cache and write-buffer manager.
This allows the persistence backend and the Raft WAL to share one
unified memory budget, which prevents either subsystem from starving the
other.
RocksDB stores all data for a logical key in a contiguous region of the sorted keyspace through suffix conventions:
{key}~CURRENT stores the current version.{key}~{revision} stores a specific revision in the
history.{key}~NOREV stores the no-revision provenance
marker.The ~ separator ensures that all versions of a key sort
together. The constant CurrentMarker is defined as
"~CURRENT", with a pre-encoded UTF-8 variant
CurrentMarkerUtf8 for zero-allocation key construction.
Write operations use a WriteBatch to group all mutations
into a single atomic write. Key buffers come from
ArrayPool<byte>.Shared when the key exceeds 256 bytes
(KeyStackThreshold), or from stackalloc for
shorter keys. Values are serialized through protobuf via
CodedOutputStream.
No-revision provenance is merged batch-locally: a
Dictionary<string, (HLCTimestamp, HLCTimestamp)>
accumulates the earliest and latest timestamps across all items in the
batch, then persists one row per key through BuildNoRevKey
and PackNoRev. The provenance row is 48 bytes
(NoRevProvenanceSize, which is 6 longs).
Point reads construct the ~CURRENT key in a
stackalloc buffer and call db.Get. Batched
point reads use db.MultiGet with a per-key
~CURRENT suffix and column family array.
Revision reads (GetKeyValueRevisionAtOrBefore) seek a
forward iterator to {key}~ and scan all revision rows. The
method keeps the best match: the highest revision at or below the
requested ceiling whose LastModified does not exceed the read timestamp.
Sibling keys are rejected through a full decimal parse of the
suffix.
Prefix and range reads also use iterators. They filter for
~CURRENT suffixed keys and cap results at the caller’s
limit.
The PruneRevisionsForKey method sorts revisions in
descending order and applies two retention rules: a count threshold
(retain the newest N) and an age threshold (retain revisions whose
lastModifiedPhysical is at or above the cutoff). Two
categories of revisions are always protected: the current revision and,
when a pruned-history floor is active, the boundary revision (the
highest revision whose LastModified does not exceed the floor timestamp)
plus everything newer.
After deleting revisions, the method stages the pruned-history floor
(the oldest surviving revision’s HLC) in the same
WriteBatch. This ensures that a crash cannot leave the
floor trailing behind the actual deletes.
The backend-wide sweep iterates ~CURRENT rows through a
sweepCursor, bounded by a configurable batch size for both
keys inspected and revisions deleted per pass. The cursor wraps around
when it reaches the end of the keyspace.
CreateCheckpointAsOf first takes a native RocksDB
checkpoint, then trims the copy. The trim pass streams through every key
and uses a KeyTrimState to decide which rows to keep. Write
batches flush every 4096 operations
(TrimBatchFlushThreshold). The lock column family is
dropped entirely from as-of images. A final full compaction of both
column families physically purges tombstoned data from the trimmed
copy.
BackgroundWriterActor is a fire-and-forget actor (it
implements IActor<BackgroundWriteRequest>) that moves
committed state from the in-memory actor layer to the persistence
backend. Chapter 18 described its role as one of the five core actor
types. This section covers its internal mechanics.
The writer maintains two separate queues: dirtyLocks and
dirtyKeyValues, both of type
Queue<BackgroundWriteRequest>. When actors commit
state through Raft, they enqueue write requests into these queues.
Each flush cycle drains both queues in batches. The maximum batch
size is 1024 items (MaxBatchSize) or 512 KB
(MaxPacketSize, which is 524,288 bytes), whichever limit is
reached first. The flush cycle runs within a time budget set by
DirtyObjectsWriterDelay (default 5000 ms).
Each batch is retried up to 5 times (WriteRetries) with
a base delay of 1000 ms between attempts. If all retries fail, the batch
is retained in pendingLockItems or
pendingKeyValuesItems for the next cycle. This retention
ensures that no committed data is silently dropped.
After a successful key-value flush, the writer calls
flushNotificationSink.NotifyFlushed(key, revision) for each
item in the batch. This notification routes a FlushAck
message back to the owning KeyValueActor, which advances
the FlushedRevision field on the entry.
Pooled request objects are returned to
BackgroundWriteRequestPool after processing.
The writer owns a PartitionDurabilityTracker that tracks
the relationship between Raft log indexes and their persistence status.
The tracker maintains per-partition state through four durability
channels:
Each channel has its own HighestApplied ceiling. The
tracker computes a watermark per partition: the highest contiguous
resolved index (computed as the first pending index minus one, or
HighestRegistered if no entries are pending). The
AdvanceDurabilityFloors method persists floor updates for
every partition whose watermark advanced past the last persisted
value.
The Forget method retires a partition by setting a
Removed flag under a lock, then removing the exact object
from the dictionary. This prevents racing registrations from mutating an
orphan state object.
The writer performs two forms of revision cleanup:
Targeted cleanup
(RunTargetedRevisionCleanup): prunes revisions for recently
written keys. The set of candidate keys is bounded at 10,000 entries
(MaxPendingCleanupKeys). The method opens a prune-delete
window through BeginPrune/EndPrune on the
snapshot floor store and uses a
ConfirmFloorRegistryFreshness gate to ensure safe
pruning.
Full sweep (RunFullRevisionSweep):
a backend-wide scan at the configured
PersistentRevisionCleanupInterval. The sweep resumes
immediately when a backlog remains. Both forms use the same
floor-sampling window pattern.
The CheckpointPartitions method runs at the end of each
flush tick. It checkpoints partitions whose dirty-since time exceeds
CheckpointInterval. The method skips partitions with
unflushed writes and drops non-leader partitions. It captures ceilings
from the durability tracker for three channels (Receipts,
TransactionRecords, PreparedIntents), persists each store’s snapshot,
and calls raft.ReplicateCheckpoint to distribute the
checkpoint to followers.
UnflushedOverlayPersistenceBackend is a decorator that
wraps any IPersistenceBackend and merges
queued-but-not-yet-persisted writes into every read path. This ensures
read-your-writes consistency between the commit frontier and the flush
frontier.
The overlay maintains two indexes:
UnflushedKeyValueWritesIndex for key-value writes and
UnflushedLockWritesIndex for lock writes. When the
background writer successfully flushes a batch, it calls
RemoveFlushed(key, revision, lastModified) on the overlay
to retire each item. If the flush fails, overlay entries remain so they
continue to cover retried writes.
Every read method follows the same pattern:
The freshness check (IsInnerNewer) compares revisions
first, then LastModified timestamps at equal revisions: the inner entry
wins only if
entry.Revision > queued.Revision || (entry.Revision == queued.Revision && entry.LastModified > queued.LastModified).
Batched reads (GetKeyValues) short-circuit the overlay
scan when unflushedWrites.IsEmpty, which avoids overhead
when the flush frontier has caught up.
Prefix and range scans use a MergeScan algorithm. The
method builds a dictionary of disk and overlay entries keyed by string.
The overlay entry wins unless the disk entry has a higher revision (or
the same revision with a later LastModified). The result is sorted by
key order and capped at the caller’s limit. Deleted overlay entries are
kept (tombstone surfacing) so that the caller observes deletes that have
not reached disk.
Cursor-based whole-family scans (ScanKeyValues,
ScanLocks) pass through to the inner backend without
overlay merging. These scans cannot window the unflushed set safely
because the cursor state belongs to the backend. Callers that need
consistency must drain the background writer before starting a full
scan.
Write operations (StoreKeyValues,
StoreLocks) delegate to the inner backend. On success, the
overlay removes the flushed entries. Delete, prune, and checkpoint
operations pass through directly because they operate on already-flushed
data.
FlushNotificationSink is a late-bound bridge between the
background writer and the key-value layer. The sink holds a single
callback (Action<string, long>) that is set when
KeyValuesManager is constructed. The background writer
calls NotifyFlushed(key, revision) after each successful
batch write. The callback routes a FlushAck message to the
owning KeyValueActor.
When the actor receives a FlushAck, it advances
FlushedRevision on the matching entry. The update rule is:
if the acked revision is at or below the entry’s current revision and
greater than the entry’s current FlushedRevision, set
FlushedRevision to the acked value. An entry is considered
dirty (not evictable) when Revision > FlushedRevision.
The default FlushedRevision is -1, so every entry starts
dirty.
This mechanism creates a closed feedback loop:
NotifyFlushed on the sink.FlushAck to the actor.FlushedRevision on the entry.FairReadScheduler (in the Kommander library) provides
bounded thread pools for backend IO operations. The background writer
uses a dedicated write scheduler. Read operations from actors use a
separate read scheduler. This separation prevents long-running writes
from blocking reads.
Each scheduler enforces a maximum queue depth. When the queue is
full, the scheduler throws
ReadBackpressureExceededException, which includes the
partition ID and the maximum queue depth. The exception propagates back
to the client as a retriable error, signaling that the node is under IO
pressure. The BackgroundWriterActor catches this exception
in FlushLocks, FlushKeyValues, and
AdvanceDurabilityFloors to defer work to the next cycle
rather than crashing.
Each KeyValueActor maintains an in-memory B-tree
(BTree<string, KeyValueEntry> with branching factor
32) as its working cache. The B-tree entries are linked in an intrusive
doubly-linked list that tracks access order from coldest (head) to
hottest (tail). The actor checks whether eviction is necessary every 500
operations (CollectThreshold).
An actor is over budget when either condition is true:
MaxEntriesPerActor.MaxBytesPerActor.The TryCollectHandler runs a five-step eviction cycle.
Each step is bounded to prevent one collection pass from blocking the
actor for too long. Steps use resumable cursors so that work continues
across multiple cycles.
Step 1: Tombstone drain. The handler pops entries
from the tombstone queue. It skips entries that are dirty
(IsDirty()), have live write intents, or have replication
intents. Skipped entries are deferred for a future cycle. The step
processes at most CollectBatchMax entries.
Step 2: Expiry heap drain. The handler pops entries
from the expiry priority queue (keyed by HLC timestamp). It validates
each entry: the key must still exist in the store, the Expires timestamp
must match the heap entry (an Extend operation changes the timestamp and
makes the heap entry stale), and the entry must not be dirty or
intent-held. Deferred entries are re-enqueued after the loop completes
to prevent infinite re-popping. The step inspects at most
inspectionMax entries.
Step 3: LRU eviction. The handler walks the
intrusive linked list from coldest (head) to hottest (tail). It resumes
from the lruCursor saved by the previous cycle. For each
entry, it checks: is the entry clean (not dirty)? Does it have no live
write intent? If both conditions are true, the entry is evictable. The
handler projects the count and byte size after planned evictions and
stops when the actor would be within budget. The budget check uses raw
store bytes only, not heap overhead. The step is bounded by both
CollectBatchMax evictions and inspectionMax
inspections. If work remains, the handler saves its cursor position.
Step 4: Idle-TTL sweep. The handler walks the LRU
list from coldest and evicts clean entries whose age (current time minus
LastUsed) exceeds CacheEntryTtl. The sweep stops early when
it reaches the first entry that is still within its TTL, because the
list is ordered by access time. This step runs independently of budget
pressure.
Step 5: Additional sweeps. Three targeted sweeps run at the end:
SweepExpiredPredicateLocks: a bounded scan of prefix
and range lock dictionaries through a resumable key cursor. Removes
expired locks.SweepExpiredReads: expires registered in-flight backend
reads that passed their deadline.SweepDeadMvccSnapshots: walks the LRU list and trims
dead-session MVCC read snapshots that have zero expiry.The handler detects a backlog when the LRU cursor is not null after
the cycle (work remains) or when the expiry heap reached its inspection
limit while still finding entries to evict. On backlog detection, the
handler calls ScheduleFollowUpCollect() to trigger another
cycle without waiting for the next 500-operation countdown.
Two invariants protect correctness:
Never evict a dirty entry. An entry is dirty
when Revision > FlushedRevision. Evicting a dirty entry
would lose committed data that the background writer has not yet
persisted. The eviction cycle skips dirty entries in every
step.
Never evict an entry with a live write intent. A
write intent means a transaction has prepared but not committed.
Evicting such an entry would break the transaction’s isolation
guarantees. The HasLiveWriteIntent check also clears
expired intents: if the intent has a nonzero Expires timestamp and the
deadline passed, the method clears the intent and returns false (the
entry is then evictable).
Backend IO stall. When the storage backend cannot
keep pace with read requests, the FairReadScheduler queue
fills up. The scheduler throws
ReadBackpressureExceededException, which propagates to the
client as a retriable error. The client can retry after a delay or route
the request to a different node. The IO stall does not compromise data
integrity because the Raft log remains the source of truth.
Background writer lag. If the background writer falls behind the commit rate, the unflushed overlay grows. Reads remain correct because the overlay merges queued writes into every read path. The lag increases memory usage and may delay checkpoint creation (because partitions with unflushed writes are skipped). The system self-corrects when the write load decreases, because the background writer processes its queue in batches on each tick.
Dirty entry eviction attempt. The eviction cycle
encounters a dirty entry and skips it. This is the intended behavior:
the entry stays in memory until the background writer flushes it and the
FlushAck advances FlushedRevision. The skip
prevents data loss. If the cache is under heavy pressure and most
entries are dirty, the actor remains over budget until the background
writer catches up. This situation is self-correcting.
Flush retry exhaustion. The background writer
retries each batch up to 5 times. If all retries fail, the batch is
retained in pendingLockItems or
pendingKeyValuesItems for the next flush cycle. No
committed data is lost. The next cycle attempts the same batch again. If
the backend remains unavailable, the unflushed overlay continues to
serve the queued data to readers.
Pruned history floor corruption. The SQLite backend
tracks the oldest surviving revision timestamp. If the floor becomes
unreadable, the backend sets FailClosedFloor (maximum
possible HLC values), which blocks all as-of checkpoints. This prevents
an incomplete checkpoint from being produced. The operator must repair
or recreate the database to restore as-of checkpoint capability.
A distributed system is only as reliable as its recovery path. Kahuna can replicate writes across a majority of nodes, route around failed leaders, and split overloaded ranges. None of these capabilities matter if a restarted node loses committed data, or if a crashed coordinator leaves orphaned intents that block future writes forever. This chapter explains how Kahuna recovers from node failures, leader changes, and data loss. It covers Raft log replay, state restoration through dedicated restorer classes, transaction recovery with the presumed-abort rule, whole-partition state transfer for follower catch-up, and point-in-time recovery from backups.
The chapter builds on three earlier chapters. Chapter 17 introduced Raft consensus and the replication pipeline. Chapter 20 explained the two-phase commit protocol and durable intents. Chapter 22 described the persistence backends and the background writer. Recovery ties these subsystems together: it is the code path that reconstructs consistent state from the durable artifacts they produce.
When a Kahuna node starts, the ReplicationService (a
hosted BackgroundService) orchestrates the full startup
sequence. The sequence has two paths: a normal start and a point-in-time
recovery (PITR) bootstrap.
Normal start. The service calls
JoinCluster on the Kahuna manager. Kommander opens the
write-ahead log (WAL), discovers which partitions this node hosts, and
replays committed log entries through the OnLogRestored
callback. After replay completes for all partitions, the node begins to
accept new Raft proposals and client requests.
PITR bootstrap. If the operator passes both
--join-existing and --pitr-backup-dir flags,
the service runs a PITR restore before it joins the cluster. The restore
resolves the backup chain through
BackupCatalog.ResolveAndValidateAsync, flushes persistence,
and calls BootstrapHelper.BootstrapNodeAsync with the chain
artifacts, the target timestamp, the persistence backend, and a WAL
adapter. The helper installs the base snapshot and replays incremental
WAL segments up to the target HLC timestamp. Once the bootstrap
completes, the node joins the cluster normally. The PITR path is an
offline operation: the node does not serve traffic during the
restore.
On startup, Kommander replays every committed log entry from the WAL
in log-index order. For each entry, it calls OnLogRestored
on the ReplicationService, which forwards the entry to the
Kahuna manager. The manager uses
ReplicationLogRouter.OwnerOf to determine which subsystem
owns the entry: key-value entries go to
KeyValueReplicationDispatcher, and lock entries go to the
lock subsystem.
KeyValueReplicationDispatcher dispatches each replayed
entry by its replication type. Six types exist:
KeyValueRestorer.Restore. The restorer deserializes the
KeyValueMessage, decodes the entry state, registers the
entry as pending with the durability tracker, records it in the
unflushed-writes overlay, and queues a QueueStoreKeyValue
command to the background writer. If the entry carries a transaction ID,
the restorer also rebuilds the completion receipt from the transaction
ID, key, and record anchor key.RangeMapStore and synchronized with
KeySpaceRegistry. These entries define the partition layout
(range boundaries, generation numbers).SnapshotFloorStore. These entries represent active MVCC
snapshot holds that constrain revision pruning.TransactionRecordStore. These entries are the canonical
commit/abort decisions for transactions.PreparedIntentStore. These entries represent the prepare
phase of a two-phase commit.CompletionReceiptStore. These entries prove that a
transaction has been fully settled on a given partition.The restore path and the live replication path share the same
dispatcher but call different methods. Restore uses Restore
methods, while live replication uses Replicate methods. The
distinction matters because live replication applies deduplication
through the DurableApplyResultLedger for transaction
records and prepared intents. The restore path does not deduplicate
because it replays entries in strict log order and each entry appears
exactly once.
KeyValueRestorer is the workhorse of key-value recovery.
Its Restore method performs five steps for each replayed
entry:
KeyValueMessage.DurabilityTracker that a write is in flight for this
partition. The tracker uses this information to report accurate
durability watermarks to Kommander.QueueStoreKeyValue command to the background writer actor.
The background writer batches these commands and flushes them to the
storage backend in bulk.The restorer also rebuilds completion receipts when the entry carries
a transaction ID. A completion receipt proves that a transaction result
has been applied to a specific key on a specific partition. Rebuilding
receipts during replay ensures that the
CompletionReceiptStore is accurate after restart.
Lock entries follow the same pattern. The lock subsystem replays each lock entry from the WAL and restores the lock state into the lock actor. Persistent locks survive restarts because they are replicated through Raft. Ephemeral locks (locks with a lease duration) do not survive restarts. When a node restarts, ephemeral lock leases expire naturally because the holder can no longer renew them. The lock actor does not need special expiration logic during recovery: the standard lease-expiry check handles the cleanup.
A leader change is a common event in a Raft group. It happens when the current leader crashes, becomes partitioned, or steps down. The new leader must reconstruct the state that the old leader held in memory before it can serve reads and accept writes.
Kommander fires OnLeaderChanged when a partition’s
leadership changes. The Kahuna manager forwards this event to the
sequencer subsystem first, then to the key-value subsystem. The
key-value dispatcher’s OnLeaderChanged handler is
intentionally a no-op. The reason is architectural: cache coherency on
the new leader is maintained not by replaying state during the leader
change, but by InvalidateOrApply messages that flow through
the normal replication path. When the new leader begins to process
proposals, the replication pipeline ensures that its in-memory state
converges with the committed log.
The sequencer subsystem handles leader changes differently. When a
partition gains leadership, its SequenceActor must reserve
a new block of sequence values from the Raft log before it can issue new
sequence numbers. This ensures monotonicity: the new leader never
reissues a value that the old leader might have issued but not yet
replicated.
Transactions are the most complex recovery target because a transaction can span multiple partitions, and a coordinator crash can leave intents stranded on any of them. Kahuna uses the presumed-abort rule: any transaction without a committed decision record is presumed aborted.
DurableTransactionRecovery runs periodic sweeps on every
leader partition. Each sweep calls
PreparedIntentStore.DueForRecovery to find prepared intents
that have passed their recovery deadline. The sweep groups these intents
by transaction ID, epoch, and record anchor key, then calls
DecideAsync on each group.
DecideAsync follows a decision tree:
LookupRecordDelegate to read the transaction record from
the anchor partition.AbortTransactionCommand with
TransactionAbortClass.PresumedAbort and drive it at the
anchor partition. The abort is not guaranteed to win: if the coordinator
commits concurrently, the commit wins the CAS race on the transaction
record. The sweep returns whatever decision actually prevails.After a decision is made, ResolveGroupAsync settles the
intents. For a commit, each intent is materialized into a key-value
entry through PreparedIntentMaterializer.ToKeyValueRecord,
replicated through Raft, and applied locally. For an abort, all intents
are immediately marked as settleable. In both cases, the method issues a
combined resolve-and-remove delta through
PreparedIntentStore.SerializeDelta via the Raft replication
path. This ensures that all replicas apply the resolution in the same
log order.
Besides the periodic sweep, DurableTransactionRecovery
offers targeted helping through
TryResolveDecidedBlockersAsync. When a transaction’s
finalize phase discovers that another transaction’s intents block its
keys, it calls this method. The helper looks up the canonical record for
each blocking intent. If the record is terminal (committed or aborted),
the helper settles the blocking intents immediately. If the record is
undecided, the helper does not apply presumed-abort. Only the periodic
sweep aborts undecided transactions, and only after the deadline
passes.
SettleSuppliedIntentsAsync serves a different purpose:
it ensures that no unresolved intents cross a range boundary during a
split or merge. Before the cutover step of a range operation, the system
calls this method with all intents in the affected range. The method
settles intents whose canonical decision is terminal. An undecided
intent past its recovery deadline goes through the presumed-abort
protocol. An intent still within its deadline is left alone. The method
returns the count of unsettled intents. A zero count means the range can
proceed to the copy/cutover step.
When a follower’s WAL has been compacted below the entries it needs, the follower cannot catch up through normal log replication. Kommander triggers a whole-partition state transfer instead. Two transfer implementations exist: one for user data partitions and one for the meta partition.
PartitionStateTransfer implements
IRaftPartitionStateTransfer. The export side drains the
background writer first to ensure all committed state is on disk, then
pages out all data belonging to the partition: key-value rows (via
PartitionDataEnumerator), persistent locks, completion
receipts, transaction records, and prepared intents. Each page carries
an FNV-1a 64-bit checksum. The snapshot reflects at least the requested
log index; newer state is allowed.
The import side uses a two-phase install process:
installGate semaphore (mutual exclusion with unhost-purge
operations). Write a durable install marker. Drain persistence. Purge
existing backend rows for the partition. Apply all key-value rows, lock
rows, completion receipts, transaction records, and prepared intents.
Persist per-partition store snapshots. Run resident-state invalidation
hooks to drop cached lock leases and key-value entries. Clear the
install marker only on full success.A crash during the install phase leaves the durable marker file
(partition-install-{partitionId}_{storageRevision}.incomplete)
on disk. On the next startup, IsInstallIncomplete detects
the marker, and the sender retries the entire transfer from scratch. The
marker ensures that a partial install never produces a corrupt
partition.
PurgeUnhostedPartitionAsync removes all data for a
partition that this node no longer hosts. It deletes backend rows, store
slices, the durability floor, and the install marker. The purge is
serialized against imports through the same installGate
semaphore. If the partition is re-gained during the purge, the operation
aborts.
MetaSystemStateTransfer implements
IRaftSystemStateTransfer. The meta partition hosts two
state machines: RangeMapStore (partition layout) and
SnapshotFloorStore (MVCC hold registry). The export side
serializes both stores into a single
MetaSystemStateMessage. The import side deserializes and
validates both sub-states before mutating either store. This atomicity
guarantee prevents a state where one store is updated and the other is
stale. Both stores are idempotent under log-tail replay.
The SnapshotFloorStore is a replicated,
reference-counted, leased registry of MVCC snapshot holds. It lives on
the meta partition. Each hold pins a minimum HLC timestamp: the revision
pruning system will not delete any revision at or after the held
timestamp.
Mutations replicate as keyed deltas on the meta partition through the
SnapshotFloor replication type. The deltas are idempotent
by hold ID. Three operations exist:
AcquireAsync creates a new hold with a lease
duration.RenewAsync extends the lease of an existing hold.ReleaseAsync removes a hold.All three are leader-only operations, serialized locally by a
mutateGate and globally by the meta Raft log.
Two safety mechanisms protect against stale holds:
PurgeExpiredHoldsAsync
runs periodically and removes holds whose lease has elapsed. This
prevents a crashed holder from pinning MVCC history forever.BeginPrune and
EndPrune define a window during which an acquire fails
closed with MustRetry. This prevents a new hold from being
created in the gap between when the pruner reads the floor and when it
deletes old revisions.The store persists its full hold set to
snapshotfloor_{storageRevision}.snapshot through atomic
tmp-and-rename. On cold restart, LoadFromDisk reconstructs
the hold set from this snapshot file.
Chapter 27 covers backup operations in detail. This section explains only the recovery-relevant mechanics.
BackupFacade wraps BackupService with error
mapping and node wiring. A node without a configured
BackupDir receives BackupFacade.Disabled(),
which answers IsConfigured = false to all callers.
The facade wires four barriers into the backup pipeline:
flushPersistenceAsync delegate forces the background writer
to flush all queued writes before the checkpoint capture begins.KeyValuesManager.AcquireSnapshotHold. This hold pins
revision history at the backup cut so that pruning cannot reclaim
revisions during the checkpoint, hash, verify, and publish window. The
hold is renewed at roughly one-third of the lease interval. A failed
renewal aborts the backup.raft.AcquireRetentionHold to prevent WAL
compaction of incremental prefixes during the read.The retention policy is configurable: MaxChains limits
the number of retained backup chains, MaxAge limits how far
back chains can reach, and MaxTotalBytes caps total
storage. A periodic BackupGcReaperActor enforces the policy
when BackupGcInterval is greater than zero.
Backup operations include TakeFullAsync,
TakeIncrementalAsync, TakeCoordinatedAsync,
ListAsync, GetChainAsync, and
RestoreToAsync. All operations return typed
KahunaBackupOutcome values. Failure outcomes include
NeedsFull, ParentMissing,
TargetConflict, TargetOutsideCoverage,
TopologyChanged, RetryableLeadershipLoss,
CorruptArtifact, CorruptChain, and
IoError.
KahunaDurabilityProvider implements Kommander’s
IApplicationDurabilityProvider interface. It reports the
highest log index whose state has reached durable storage for a given
partition. Kommander uses this information to decide when it can compact
the WAL: entries below the durability floor are safe to discard because
the application can reconstruct them from the storage backend.
The provider returns the maximum of two values:
PartitionDurabilityTracker tracks in-flight writes
registered by the restorer and the replication path. When the background
writer confirms a flush, the tracker advances the watermark.IPersistenceBackend.GetDurabilityFloor method returns the
floor stored on disk. This value is read once per partition at startup
and cached.Forget(partitionId) evicts the cached floor when a
partition is un-hosted. This prevents a stale floor from vouching for
data that was purged during a replica move.
Node crash and restart. Kommander replays the WAL. Restorers rebuild in-memory state. The background writer flushes any entries that did not reach the storage backend before the crash. The durability tracker reports accurate watermarks after the flush completes. The node is ready to serve traffic.
Leader change with in-flight transactions. The old
leader’s uncommitted proposals are lost. The new leader’s
DurableTransactionRecovery sweep finds orphaned intents on
its partitions once their recovery deadline expires. The sweep looks up
each transaction’s canonical record. If no record exists, the sweep
presumes abort and drives an abort at the anchor partition. Committed
transactions are materialized normally.
Incomplete state transfer. If a node crashes during
a partition state transfer, the durable install marker remains on disk.
On restart, IsInstallIncomplete detects the marker. The
sending node retries the entire transfer. The verify-then-install
sequence ensures that no corrupt data contaminates the partition.
WAL corruption. If the WAL on a single node is corrupt, Raft recovers the node through state transfer from a healthy peer. The peer exports the full partition state with checksums. The recovering node verifies the checksums before installing the state.
Abandoned transactions. A coordinator process can crash after preparing intents but before writing a decision record. The intents become orphans. The recovery sweep on each participant partition detects the orphaned intents after the recovery deadline. Because no decision record exists, the sweep applies presumed-abort. The abort command is replicated through Raft so all replicas converge on the same decision.
Split with unresolved intents. Before a range split
proceeds to the cutover step, the system calls
SettleSuppliedIntentsAsync to resolve all intents in the
affected range. The method settles terminal intents immediately.
Undecided intents past their deadline are aborted through the
presumed-abort protocol. Intents still within their deadline are left
alone, and the split waits. Only when the unsettled count reaches zero
does the split proceed.
A Kahuna cluster that is configured correctly can tolerate node failures, sustain high throughput, and recover without operator intervention. A cluster that is configured incorrectly (an even number of nodes, no HTTPS, a replication factor that exceeds the node count) will fail in ways that are difficult to diagnose under pressure. This chapter explains how to deploy Kahuna, how to choose the right number of nodes, and how to configure HTTPS, storage paths, and replication.
The chapter builds on several earlier chapters. Chapter 16 introduced partitions and the meta partition. Chapter 17 explained Raft consensus and the quorum rule. Chapter 22 described the persistence backends. Chapter 23 covered recovery. Deployment ties these concepts together into a set of practical decisions: how many nodes, how many replicas, what storage backend, and what network topology.
Standalone mode runs a single Kahuna node with no peers. It is suitable for local development, testing, and evaluation. To start a standalone node, install the .NET global tool and run it with no arguments:
dotnet tool install -g Kahuna.Server
kahuna-serverThe server starts on HTTP port 2070 and binds to all interfaces. It
creates three partitions by default (the
--initial-cluster-partitions flag overrides this value).
Data is stored in the default home directory:
~/.local/share/kahuna%LOCALAPPDATA%\kahunaThe KAHUNA_HOME environment variable overrides the root
directory. Within that root, Kahuna creates two subdirectories:
data/ for the storage backend and wal/ for the
write-ahead log. The --storage-path and
--wal-path flags override these paths individually.
Standalone mode works by creating phantom witness nodes. The
EmbeddedKahunaNode class generates phantom peers so that
the single node satisfies the Raft majority-quorum requirement (two out
of three) without real network traffic. This design means the node uses
the same Raft code path as a multi-node cluster. There is no separate
single-node protocol.
Standalone mode uses RocksDB by default. The --storage
flag selects the backend:
rocksdb (default): persistent, production-grade,
column-family isolation.sqlite: persistent, 8-shard parallel writes, simpler
operational footprint.memory: volatile, fastest for tests, data is lost on
restart.The --wal-storage flag selects the write-ahead log
backend independently. Both flags accept the same three values.
A multi-node cluster requires three things: an odd number of nodes, a list of peer addresses, and HTTPS certificates for inter-node communication. Each node must know the addresses of its peers at startup.
Kahuna uses static discovery. Each node receives the addresses of its
peers through the --initial-cluster flag. The flag accepts
one or more peer URLs. A node does not list itself: it lists the other
nodes.
For a three-node cluster on separate hosts:
# Node 1 (host1)
kahuna-server \
--raft-nodename kahuna1 --raft-nodeid 1 \
--raft-host 0.0.0.0 --raft-port 2070 \
--https-ports 2071 --https-certificate /path/to/cert.pfx \
--initial-cluster https://host2:2071 https://host3:2071
# Node 2 (host2)
kahuna-server \
--raft-nodename kahuna2 --raft-nodeid 2 \
--raft-host 0.0.0.0 --raft-port 2070 \
--https-ports 2071 --https-certificate /path/to/cert.pfx \
--initial-cluster https://host1:2071 https://host3:2071
# Node 3 (host3)
kahuna-server \
--raft-nodename kahuna3 --raft-nodeid 3 \
--raft-host 0.0.0.0 --raft-port 2070 \
--https-ports 2071 --https-certificate /path/to/cert.pfx \
--initial-cluster https://host1:2071 https://host2:2071Each node receives a unique --raft-nodename and
--raft-nodeid. The node name is a human-readable label. The
node ID is a numeric identifier that Raft uses internally. Both must be
unique across the cluster.
The --join-existing flag tells a node to join a running
cluster through seed-based discovery. Instead of forming a new cluster,
the node contacts the seed addresses listed in
--initial-cluster and requests membership. This path is for
adding nodes to a cluster that is already operational.
kahuna-server \
--raft-nodename kahuna4 --raft-nodeid 4 \
--raft-host 0.0.0.0 --raft-port 2070 \
--https-ports 2071 --https-certificate /path/to/cert.pfx \
--initial-cluster https://host1:2071 https://host2:2071 \
--join-existingThe ReplicationService detects the
--join-existing flag and calls
JoinCluster(seeds, stoppingToken) on the Raft manager. The
seed-based path sends a membership request to the existing cluster. The
cluster’s current leader processes the request and adds the new node as
a member. Once accepted, the new node receives the partition map and
begins to participate in replication.
The --graceful-leave-on-shutdown flag commits a
RemoveMember operation when the node shuts down. This tells
the cluster to remove the node from the membership roster. Do not enable
this flag during rolling restarts: a rolling restart temporarily removes
and re-adds each node, which causes unnecessary partition
re-assignments.
The Kahuna repository includes a Docker Compose file
(docker/local.yml) that defines a three-node cluster. This
section walks through its structure.
The Docker images use a two-stage build. The build stage compiles the
source on the mcr.microsoft.com/dotnet/sdk:10.0 image. The
runtime stage copies the compiled output to the
mcr.microsoft.com/dotnet/aspnet:10.0 image. The runtime
image is smaller because it does not include the SDK.
The standalone image (Dockerfile.standalone) is
published to Docker Hub as kahunakv/kahuna. It exposes two
ports: 8081 (HTTP) and 8082 (HTTPS). It creates a /data
volume with data/ and wal/ subdirectories. The
entrypoint script starts the server in standalone mode with no
--initial-cluster flag.
The cluster image (DockerfileLocal) accepts build
arguments for node identity, ports, and the peer list. The entrypoint
command passes these values as command-line flags:
dotnet /app/Kahuna.Server.dll \
--raft-nodename $KAHUNA_RAFT_NODENAME \
--raft-nodeid $KAHUNA_RAFT_NODEID \
--raft-host $KAHUNA_RAFT_HOST \
--raft-port $KAHUNA_RAFT_PORT \
--http-ports $KAHUNA_HTTP_PORTS \
--https-ports $KAHUNA_HTTPS_PORTS \
--https-certificate /app/certificate.pfx \
--initial-cluster $KAHUNA_INITIAL_CLUSTER \
--storage rocksdb \
--storage-path /storage/data \
--wal-storage rocksdb \
--wal-path /storage/walThe local.yml file defines three services
(kahuna1, kahuna2, kahuna3) on a
bridge network with static IP addresses (172.30.0.2,
172.30.0.3, 172.30.0.4). Each node maps two
host ports (HTTP and HTTPS) to the container’s internal ports 2070 and
2071:
| Service | HTTP (host) | HTTPS (host) | Static IP |
|---|---|---|---|
| kahuna1 | 8081 | 8082 | 172.30.0.2 |
| kahuna2 | 8083 | 8084 | 172.30.0.3 |
| kahuna3 | 8085 | 8086 | 172.30.0.4 |
Each service uses a named volume (for example,
kahuna1-data) mounted at /storage. This volume
persists RocksDB data and WAL files across container restarts.
Each node’s --initial-cluster flag lists the HTTPS
addresses of the other two nodes, using their static IPs and internal
port 2071. Node 1, for example, receives
https://172.30.0.3:2071 https://172.30.0.4:2071.
To start the cluster:
docker compose -f docker/local.yml up -dLog levels are configurable through environment variables:
KAHUNA_SERVER_DEFAULT_LOG_LEVEL controls the root logger
level, and KAHUNA_SERVER_LOG_LEVEL controls the
Kahuna-specific logger level.
After starting the containers, verify that all nodes are ready.
Kahuna exposes a health endpoint at GET /v1/cluster/health.
The endpoint returns HTTP 200 when the node is ready to serve requests,
or HTTP 503 when the node is still initializing.
curl -k https://localhost:8082/v1/cluster/healthA node can open its port and respond to HTTP requests roughly one second after launch. However, it refuses every key-value request until cluster initialization completes (partition map received and applied). This window can last tens of seconds after a restart.
The readiness logic checks three conditions:
NotMember (the node has
not been evicted).Leaving (the node is not
in the process of decommissioning).For deeper readiness verification, the CI scripts use a two-phase
probe. Phase one checks HTTP liveness with a simple connection test.
Phase two sends a try-lock request and waits for any
response other than MustRetry. A non-retry response
confirms that leader election is complete and the node can process
writes.
The cluster API provides several informational endpoints:
GET /v1/cluster/membership returns the roster of
cluster members, including their roles, versions, and connectivity
state.GET /v1/cluster/placement returns the partition map:
which partitions exist, which nodes host replicas of each partition, and
the current replication factor.POST /v1/cluster/replication-factor adjusts the
replication factor for individual partitions (see the Replication Factor
section below).POST /v1/cluster/leave initiates a graceful
decommission of the node that receives the request.The Kahuna Kubernetes Operator automates deployment and lifecycle
management of Kahuna clusters on Kubernetes. The operator is an
alpha-stage project, hosted in a separate repository
(kahunakv/kahuna-k8s-operator). It provisions clusters,
scales them, and manages their lifecycle through a custom resource
definition (CRD).
The operator handles the concerns that Docker Compose leaves to the operator: stable network identities (through StatefulSets), persistent volume claims, rolling upgrades, and automated seed-list management. Because it is alpha software, consult the operator’s repository for current installation instructions and supported features.
Kahuna uses HTTPS for two purposes: client-to-node communication and node-to-node (inter-cluster) communication. Three command-line flags control HTTPS:
--https-ports: one or more ports to bind for HTTPS.
Default: 2071 when a certificate is provided.--https-certificate: path to a PFX (PKCS#12)
certificate file.--https-certificate-password: password for the PFX
file, if encrypted.When no certificate is provided, the node runs in HTTP-only mode. If
--https-ports is specified without
--https-certificate, the server exits with a validation
error.
Two flags control the URI scheme for inter-node traffic:
--raft-http-scheme: the scheme for REST-based
inter-node calls. Default: https://.--raft-grpc-scheme: the scheme for gRPC-based
inter-node calls. Default: https://.In development and testing environments, the
--raft-allow-insecure-certificate-validation flag disables
certificate validation for all inter-node traffic. This flag allows
self-signed certificates to work without configuring a certificate
authority. Do not use this flag in production.
When a certificate is loaded, Kahuna extracts the certificate
thumbprint and stores it as the HttpsTrustedThumbprint in
the Raft configuration. This thumbprint can be used for certificate
pinning in inter-node communication.
For production deployments, use certificates signed by a trusted
certificate authority. If you use self-signed certificates, distribute
the CA root certificate to all nodes and configure the operating
system’s trust store. The
--raft-allow-insecure-certificate-validation flag is a
development convenience, not a production solution.
Cluster sizing determines how many node failures the system can tolerate while it continues to serve reads and writes. The sizing decision follows directly from the Raft quorum rule.
A Raft group requires a majority of its voting members to agree
before it commits a log entry. For a group with N voting members, the
majority is floor(N/2) + 1. The group can tolerate
N - floor(N/2) - 1 failures.
| Nodes | Quorum | Tolerated failures |
|---|---|---|
| 1 | 1 | 0 |
| 3 | 2 | 1 |
| 5 | 3 | 2 |
| 7 | 4 | 3 |
An even-number cluster wastes resources without improving fault tolerance. A cluster of 4 nodes requires a quorum of 3 and tolerates 1 failure, the same as a cluster of 3 nodes. The fourth node adds cost (CPU, memory, disk, network) without increasing availability.
Worse, an even-number cluster increases the risk of a tie during a network partition. If a 4-node cluster splits into two groups of 2, neither group has a majority. Both halves become unavailable. A 3-node cluster that loses one node still has a group of 2, which is a majority. The remaining two nodes continue to serve requests.
For these reasons, always deploy an odd number of nodes: 3 for most workloads, 5 for higher fault tolerance, 7 for environments that require tolerance of 3 simultaneous failures.
Kahuna uses the SWIM protocol (Scalable Weakly-consistent Infection-style Process Group Membership) for failure detection. SWIM runs as a protocol layer within Kommander. It detects node failures and removes failed nodes from the membership roster.
The failure detection pipeline has four stages:
--raft-ping-interval,
default 1000ms). If the peer responds within the ping timeout
(--raft-ping-timeout, default 500ms), it is marked
healthy.--raft-indirect-ping-fanout, default 2) to ping the
suspect on its behalf. This step distinguishes a failed node from a
network path failure.--raft-suspicion-timeout, default 5000ms). If it
responds during this window, it returns to healthy.--raft-dead-member-eviction-grace, default 30000ms), it is
evicted from the membership roster.The --raft-enable-auto-rejoin flag (default true) allows
an evicted node to rejoin the cluster automatically when it comes back
online.
Membership state propagates through gossip. Each node periodically selects a random subset of peers and exchanges membership information. Two flags control gossip:
--raft-gossip-interval (default 5000ms): how often a
node initiates a gossip round.--raft-gossip-fanout (default 2): how many peers
receive each gossip message.Gossip ensures that membership changes (joins, leaves, evictions) reach all nodes within a bounded number of rounds, even in the absence of a centralized coordinator.
The replication factor (RF) controls how many voter replicas exist for each partition range. Two modes of replication exist.
The default replication factor is 0, which means full replication. In this mode, every voter node hosts every partition range. A 3-node cluster with RF 0 stores three copies of every partition. This mode is the simplest to operate because every node can serve reads for every partition. Full replication is suitable for clusters with a small to moderate number of nodes (3 to 7).
When the cluster grows beyond a handful of nodes, full replication
becomes expensive. Setting --raft-replication-factor to a
positive integer enables selective replication. Each partition range
receives a replica set of exactly that size. Quorum is computed per
range over its voter replicas only.
For example, in a 9-node cluster with RF 3, each partition range is stored on 3 of the 9 nodes. The quorum for each range is 2. A single node failure does not affect any range (each range still has 2 of 3 replicas). Two node failures affect only the ranges that had replicas on both failed nodes.
Prefer odd replication factors for the same reason as odd node counts: an even RF wastes a replica without improving fault tolerance.
Kahuna validates the replication factor at startup:
The --raft-zone flag assigns a zone label to a node (for
example, us-east-1a). The replica placement algorithm uses
zone labels to spread replicas across failure domains. If three nodes
are in zone A and three are in zone B, the placement algorithm avoids
putting all replicas of a partition in the same zone. Zone-aware
placement requires at least as many distinct zones as the replication
factor.
Production deployments should place the data directory and the WAL directory on separate storage devices when possible.
Three mechanisms control storage paths, in order of precedence:
--storage-path and --wal-path flags
override individual directories.KAHUNA_HOME environment variable overrides the root
directory. Data goes to $KAHUNA_HOME/data and WAL goes to
$KAHUNA_HOME/wal.~/.local/share/kahuna on Linux
and macOS, %LOCALAPPDATA%\kahuna on Windows).The --storage-revision flag (default v1)
and --wal-revision flag (default v3) append a
version suffix to the storage path. These flags allow a node to maintain
separate storage directories for different schema versions. During an
upgrade, a new revision can coexist with the old one until migration
completes.
When both the storage backend and the WAL backend use RocksDB, the
--rocksdb-shared-memory flag enables a shared memory pool.
This pool unifies the block cache and write buffer manager across both
RocksDB instances. Two flags control the pool size:
--rocksdb-shared-memory-budget-mb (default 320 MiB):
the total budget for the shared block cache.--rocksdb-shared-memtable-budget-mb (default 128 MiB):
the budget for the shared write buffer manager.Sharing memory prevents the two RocksDB instances from competing for the operating system’s page cache. Without sharing, each instance manages its own buffer pool, and the combined memory usage can exceed the intended limit.
Kahuna sets the .NET thread pool minimum to 256 worker threads and 128 I/O completion threads at startup. This prevents the thread pool from throttling under burst load. In the default .NET configuration, the thread pool starts with a small number of threads and ramps up slowly (one thread per 500ms). The high minimum ensures that the server can handle concurrent requests immediately.
This section describes four failure scenarios and their effects on a correctly configured cluster.
When one node fails in a 3-node cluster, the remaining two nodes form a quorum (2 out of 3). All partitions that had the failed node as their leader elect a new leader from the surviving nodes. Client reads and writes continue without interruption, though latency may increase briefly during leader election. The SWIM failure detector marks the failed node as suspect, then dead, and finally evicts it after the grace period.
When two nodes fail, the single surviving node cannot form a quorum (1 out of 3). All Raft groups lose quorum. Reads of committed data may still succeed if the node serves stale reads, but writes become unavailable. The cluster remains in this state until at least one of the failed nodes recovers.
A 4-node cluster that experiences a network partition into two groups of 2 loses quorum on both sides. Neither group can commit writes. A 3-node cluster partitioned into groups of 2 and 1 continues to operate on the side with 2 nodes (the majority). This scenario is the primary reason to avoid even node counts.
An under-replicated partition has fewer live replicas than the configured replication factor. This happens when nodes fail and the cluster has not yet re-replicated the data to replacement nodes. The partition continues to serve requests as long as a quorum of its remaining replicas is intact. However, the partition is at higher risk: one more failure could cause quorum loss for that specific range.
The GET /v1/cluster/placement endpoint shows the current
replica set for each partition. Monitor this endpoint to detect
under-replicated partitions and take corrective action (restart failed
nodes or add new nodes) before additional failures occur.
Before deploying Kahuna to production, verify these items:
--raft-allow-insecure-certificate-validation in
production.--storage-path and --wal-path on separate
disks when possible. Use durable storage (SSD or NVMe), not ephemeral
volumes.GET /v1/cluster/health on every node. Alert when any node
returns 503 for more than 60 seconds.GET /v1/cluster/placement periodically. Alert on
under-replicated partitions.--graceful-leave-on-shutdown during rolling restarts.
Enable it only for permanent decommission.A production cluster changes over time. Operators add nodes for capacity, remove nodes for maintenance, and replace nodes after hardware failures. Each of these operations changes the set of machines that participate in consensus. An incorrect membership change can cause quorum loss, data unavailability, or unnecessary state transfers. This chapter explains how Kahuna tracks cluster membership, how nodes join and leave, how the failure detector works, and how to perform rolling restarts safely.
The chapter builds on two earlier chapters. Chapter 17 introduced Raft consensus, leader election, and the quorum rule. Chapter 24 covered deployment modes and cluster sizing. Membership management is the runtime counterpart to those static decisions: it governs what happens when the cluster must grow, shrink, or heal while it serves traffic.
Kahuna stores cluster membership in a versioned roster on the system
partition (partition zero). The roster is a Raft-committed data
structure, so it inherits the linearizability guarantees of the system
partition. The ClusterMembership class holds two fields: a
MembershipVersion counter and a list of
ClusterMember entries.
Every mutation (add, promote, remove, or role change) increments
MembershipVersion by exactly one. Callers carry the version
they read, so the RaftSystemCoordinator can detect stale
writes and reject them. Gossip and discovery never mutate the roster
directly. They inform the system about node liveness, but only committed
Raft entries change who is in the cluster.
Each ClusterMember entry carries four fields:
host:port address that
matches the node’s RaftNode identifier.Learner,
Voter, Leaving, or
NotMember.MembershipVersion
at which this node first entered the roster as a Learner.The NotMember role is never stored in a roster entry. It
exists only as a return value from RaftManager.LocalRole
when the local node does not appear in the committed roster at all.
A node moves through a defined set of roles during its lifetime in the cluster:
Liveness state (Alive, Suspect, Dead) lives in the gossip layer, not in the roster, so it never churns the Raft log.
A new node joins an existing cluster by starting with the
--join-existing flag and pointing
--initial-cluster at one or more seed endpoints. The
ClusterJoinService orchestrates the entire join
sequence.
JoinRequest to each seed endpoint in turn. If the seed is
not the system-partition leader, it returns a leader hint. The joining
node follows the hint and retries.JoinRequest reaches the system-partition leader, the
leader’s ReceiveJoin method commits an
AddMember entry to the system-partition Raft log. The new
node enters the roster with role Learner and the current
MembershipVersion.Learner to Voter.
If the leader determines that promotion is permanently blocked (for
example, the learner is below the WAL compaction floor and no snapshot
transfer is registered), it signals the joining node through
SetJoinTerminalReason. The joining node then fails fast
with a descriptive error instead of waiting for the 60-second
timeout.The join flow uses a hard ceiling of 60 seconds. If the node does not
reach Voter status within that window, it throws a
TimeoutException with a diagnostic snapshot that includes
the system-partition leader, the WAL frontier, the count of data
partitions started, and the initialization flag.
The ReceiveJoin handler is idempotent. If the joining
node’s endpoint is already in the roster (because a previous
AddMember committed but the response was lost), the handler
returns success with the current roster version. This prevents the
joining node’s retry loop from wasting the full 60-second timeout on a
node that is already admitted.
The system-partition leader runs
CheckLearnerPromotionsAsync on every
UpdateNodes timer tick. This method measures the
per-partition replication lag for each Learner in the roster.
For each Learner, the method iterates over all partitions (system and
data). For partitions that this node leads, the method reads the
Learner’s committed index directly from lastCommitIndexes.
For partitions led by another node, the method queries that node via
GetRemoteFollowerLag.
A Learner is considered “caught up” when its lag on every checked
partition is within LearnerPromotionLag entries of the
leader. The default value of LearnerPromotionLag is 10.
A single lag check that passes is not enough. The Learner must remain
within the lag threshold for a continuous period defined by
LearnerPromotionStableWindow. The default value is 3
seconds.
The method tracks the first moment each Learner fell within the lag
threshold in a dictionary keyed by endpoint. If the Learner stays caught
up for the full stable window, the method sends a
PromoteMember request to the system coordinator. If the
Learner falls behind at any point, the stable window resets.
At most one membership change is in flight at a time. After promoting one Learner, the method returns immediately. The next timer tick handles any remaining Learners. This serialization prevents overlapping membership changes from conflicting on the system-partition log.
If a Learner’s committed index is below the WAL compaction floor on
any partition, and no snapshot transfer is registered for that
partition, the Learner can never catch up through log replay alone. The
leader sets a terminal reason for the joining node so that the
JoinCluster method on the joining side fails fast. The
leader emits a warning log and adds the endpoint to a set so the
terminal signal is sent only once.
Kahuna supports two removal paths: the API-driven graceful leave and the shutdown-coupled leave.
An operator calls POST /v1/cluster/leave on the node to
decommission. The ClusterLeave facade wraps
RequestLeaveAsync with error mapping and a deadline. The
response includes the outcome, the roster version, whether the node’s
replicas were drained, and whether the request is retryable.
RequestLeaveAsync follows this sequence:
AutoRejoinDriver from re-admitting a node whose
removal committed late.Voter to Leaving. It then waits (up to
DecommissionDrainTimeout, default 120 seconds) for the
placement pass to evacuate every replica onto surviving nodes. When the
committed partition map no longer names this endpoint, the drain is
complete and the method proceeds to the final removal.RemoveMember request to the system-partition leader. If
this node is the leader, it applies the removal locally. The request
retries with leader-following and a 10-second deadline.The method returns a LeaveClusterResult with one of
these outcomes:
If the drain times out or is cancelled,
RollBackDrainAsync commits a role transition from
Leaving back to Voter on
CancellationToken.None (the rollback must be attempted even
after the caller’s token fires). If the placement pass committed the
final RemoveMember while the rollback was firing, the
rollback observes MemberNotFound and reports the leave as
completed. A node must never keep serving when it is out of the
committed roster.
When a node shuts down with --graceful-leave-on-shutdown
enabled, LeaveCluster runs a simpler path. It sets the
_leaving latch immediately to suppress elections on all
partitions, commits a RemoveMember entry if at least one
other Voter exists, and then tears down. This path does not drain
replicas. It is suitable for permanent decommissions where the operator
plans to stop the process immediately after the leave commits.
The book plan warns: do not enable
--graceful-leave-on-shutdown during rolling restarts. The
next section explains why.
A rolling restart replaces each node’s binary one at a time without shrinking the cluster. The correct procedure is:
--graceful-leave-on-shutdown.A graceful leave commits a RemoveMember entry. The
surviving nodes treat the node as permanently gone. When the node
restarts, it must rejoin as a Learner, catch up on all partitions, and
wait for promotion. If the cluster uses placed replicas, the departure
triggers a placement pass that evacuates the node’s replicas onto
survivors. When the node rejoins, another placement pass must
redistribute replicas back. Each of these transfers costs network
bandwidth and disk I/O.
By contrast, a restart without leave keeps the node in the roster.
The SWIM failure detector marks it as Suspect, then Dead, but the
DeadMemberEvictionGrace prevents eviction for 2 minutes by
default. A node that restarts within that window resumes its position as
a Voter without any state transfer.
The --raft-dead-member-eviction-grace flag controls how
long a Dead node survives before the system-partition leader commits a
RemoveMember. The default is 2 minutes. Set this value to
exceed the longest expected restart time for any node. A cold start with
a large RocksDB store can take 30 to 60 seconds, so the 2-minute default
provides a comfortable margin.
If a restart takes longer than the grace period, the SWIM failure detector evicts the node. Auto-rejoin then kicks in (see the next section) and the node re-enters as a Learner.
Kahuna uses a SWIM-style protocol (Scalable Weakly-consistent Infection-style Membership) to detect failed nodes. The failure detector runs independently of Raft consensus and feeds liveness information to the system-partition leader.
Every PingInterval (default 1 second), each node picks
one random peer and sends a direct ping. If the direct ping times out
within PingTimeout (default 500 ms), the node sends
indirect pings through IndirectPingFanout (default 2)
randomly chosen intermediaries. The intermediaries forward the probe to
the target and relay the response. Indirect probing reduces false
positives caused by a single faulty network path.
A node that fails both direct and indirect probes transitions from
Alive to Suspect. A Suspect node has
SuspicionTimeout seconds (default 5) to refute the
suspicion through a successful probe or gossip message. If it does not
refute within that window, it transitions to Dead.
A Dead node enters the eviction grace period. After
DeadMemberEvictionGrace (default 2 minutes), the
system-partition leader commits a RemoveMember entry that
removes the node from the roster. The eviction path re-probes the
endpoint at commit time as a final safety check.
| Flag | Default | Purpose |
|---|---|---|
--raft-ping-interval |
1000 ms | Interval between SWIM probe rounds |
--raft-ping-timeout |
500 ms | Direct ping timeout |
--raft-suspicion-timeout |
5000 ms | Time a Suspect node has to refute |
--raft-dead-member-eviction-grace |
120000 ms | Grace period before a Dead node is evicted |
--raft-indirect-ping-fanout |
2 | Number of intermediaries for indirect probes |
Setting the suspicion timeout too short causes premature evictions on slow networks. Setting it too long delays detection of genuine failures. The default of 5 seconds balances these concerns for most datacenter deployments.
When a node discovers that it is no longer in the committed roster
(typically because the eviction grace period expired during a restart),
AutoRejoinDriver re-runs the join flow automatically. The
driver uses exponential backoff (1 second to 30 seconds) and contacts
both the remaining roster members and discovery peers.
Auto-rejoin is enabled by default
(--raft-enable-auto-rejoin true). The driver is suppressed
during a graceful leave (the removal is intentional) and before the node
has ever been in a committed roster (first-time joins use their own
admission loop).
The re-admitted node enters as a Learner and is promoted to Voter by the standard promotion machinery. A node that was evicted but stayed running is typically already caught up, so promotion is fast.
RequestLeaveAsync sets a _leaveRequested
latch on its first call. This latch never clears. It blocks auto-rejoin
from re-admitting a node whose operator-ordered decommission landed
late. Without this latch, a sequence like “operator calls leave, leave
times out, removal commits a moment later, auto-rejoin fires” would
silently undo the decommission.
Kahuna exposes four cluster management endpoints:
GET /v1/cluster/membership. Returns the committed roster: each member’s endpoint, node ID, role, and joined version. Also returns the local node’s role and whether initialization is complete.
POST /v1/cluster/leave. Decommissions the local node. Returns 200 if the node left the roster, 409 if the removal was permanently refused (last voter), 503 if the node could not attempt the removal, and 504 if the attempt timed out.
GET /v1/cluster/health. Returns 200 when the node is ready to serve (initialized and in a serving role). Returns 503 during initialization, after eviction, or during decommission. Use this endpoint as a Kubernetes readiness probe.
GET /v1/cluster/placement. Returns the partition map: which nodes host each partition, in what replica role, and whether the answering node hosts each partition locally.
The following flags control membership behavior at the Kahuna server level:
| Flag | Default | Purpose |
|---|---|---|
--join-existing |
false | Join a running cluster instead of static discovery |
--initial-cluster |
(none) | Seed endpoints for --join-existing |
--graceful-leave-on-shutdown |
false | Commit a removal on shutdown |
--raft-learner-promotion-lag |
10 | Max log entries a Learner may trail the leader |
--raft-learner-promotion-stable-window |
3000 ms | How long a Learner must stay within the lag threshold |
--raft-decommission-drain-timeout |
120000 ms | How long a graceful leave waits for replica evacuation |
--raft-enable-auto-rejoin |
true | Re-join after dead-member eviction |
Premature eviction. If
--raft-suspicion-timeout is set too short (for example, 1
second on a cloud network with occasional latency spikes), the failure
detector declares healthy nodes Dead before they can refute the
suspicion. This triggers unnecessary evictions, state transfers, and
re-promotions. Increase the suspicion timeout until false positives
stop.
Quorum loss from simultaneous removals. Removing two
nodes from a three-node cluster at the same time leaves one node with no
quorum peer. The remaining node cannot commit any Raft entry, including
the RemoveMember for the second node. The cluster is
unavailable until at least one node rejoins. Always remove one node at a
time and wait for the roster to stabilize before removing the next.
Last-voter protection. ReceiveLeave
refuses a RemoveMember when the removal would leave zero
voters in the roster. The response includes Terminal: true
so the leave loop does not retry. To shut down a single-node cluster,
stop the process directly instead of calling the leave endpoint.
Rolling restart with leave. Enabling
--graceful-leave-on-shutdown during a rolling restart
causes each restarted node to leave the roster and rejoin as a Learner.
The placement rebalancer evacuates replicas on each departure and
re-distributes them on each rejoin. For a five-node cluster, this
produces ten full placement passes and potentially gigabytes of
unnecessary data movement. Disable the flag during rolling restarts.
Drain timeout. If the placement rebalancer cannot
evacuate all replicas within DecommissionDrainTimeout, the
role rolls back to Voter and the node keeps serving. Replicas that were
already moved stay moved. A retry resumes the drain from where it left
off. Increase the timeout for clusters with large partitions.
Auto-rejoin after operator removal. If an operator
removes a node through the REST API and the node stays running,
auto-rejoin re-admits it as a Learner. To prevent this, either stop the
node after the leave response, or start the node with
--raft-enable-auto-rejoin false.
Chapter 16 explained how key-range routing maps contiguous intervals of keys to partitions. It described the split lifecycle: how one range becomes two through a nine-step sequence. This chapter goes further. It covers the operational side of range management: how to register key ranges, how to trigger splits and merges (both automatic and manual), how the leader balancer spreads Raft leadership across nodes, and how the placement controller moves replicas to maintain zone-aware fault tolerance.
The chapter builds on two earlier chapters. Chapter 16 introduced
hash-based and key-range routing, the RangeMap, and the
split lifecycle. Chapter 24 covered deployment modes and replication
factor configuration. Range management is the runtime layer that keeps
partitions balanced as data grows and access patterns shift.
Before Kahuna can split or merge ranges in a key space, that key space must be registered for key-range routing. By default, every key space uses hash-based routing. Registration switches a key space to key-range mode and seeds the initial range descriptor.
The client exposes two methods:
KahunaRegisterKeyRangeResponse response =
await client.RegisterKeyRange("orders");RegisterKeyRange does two things in sequence:
KeySpaceRegistry on the contacted node changes the routing
mode for the named key space from Hash to
KeyRange. This flip is node-local and not replicated. Every
node in the cluster must receive its own registration call.EnsureKeyRangeSeededAsync on the meta-partition leader. The
leader creates a single RangeDescriptor that covers the
entire key space: StartKey=null, EndKey=null (representing
negative infinity to positive infinity). The descriptor’s
PartitionId is chosen by hashing the key-space name across
the data partition pool [1, InitialPartitions]. The
Generation starts at 1.If the seed descriptor already exists, the method returns
AlreadySeeded and does not create a duplicate. The seeding
step is idempotent. The routing-mode flip is also idempotent: calling
RegisterKeyRange twice on the same node has no additional
effect.
The response includes a Status field with one of five
values: Seeded (descriptor created),
AlreadySeeded (descriptor existed),
Indeterminate (commit status unknown),
InvalidInput (empty key space), or
KeyRangeDisabled (key-range routing is disabled on the
server).
KahunaRemoveKeyRangeResponse response =
await client.RemoveKeyRange("orders");RemoveKeyRange deletes all range descriptors for the
named key space in a single atomic mutation on the meta partition. The
method is idempotent. It refuses the call transiently if a split is in
progress and holding a quiesce window on any descriptor in that key
space. It also refuses removal of internal schema-log key spaces (the
/meta prefix).
After removal, keys in that key space fall back to hash-based routing. Data already stored in key-range partitions is not moved or deleted. To reclaim those partitions, the operator must remove them through the partition lifecycle API.
The GetRanges method returns the current range map for a
key space:
KahunaRangeMapResponse ranges = await client.GetRanges("orders");
foreach (var descriptor in ranges.Descriptors)
{
Console.WriteLine(
$"[{descriptor.StartKey}, {descriptor.EndKey}) " +
$"-> partition {descriptor.PartitionId} " +
$"gen {descriptor.Generation}");
}The response includes the RoutingMode
(KeyRange or Hash), an
Initialized flag that indicates whether the meta partition
map has been applied, and an ordered list of Descriptors.
Each descriptor carries StartKey, EndKey,
PartitionId, and Generation.
Pass a keySpace parameter to filter by key space. Omit
it to see all registered key spaces.
Kahuna supports two automatic split triggers: count-based and load-based. Both run on the meta-partition leader.
The RangeSplitTrigger runs a sampling pass every
RangeCollectionInterval seconds (default 60). For each
key-range descriptor, the trigger samples keys through
GetByRange in pages of 512, up to 4096 keys total. If the
sampled count exceeds RangeSplitThreshold (default 1000),
the trigger initiates a split.
The split key is chosen at the midpoint of the sampled keys. Both
halves must contain at least RangeSplitMinRangeSize keys
(default 10). If either half would fall below that minimum, the split is
refused as indivisible.
After a split completes, the trigger applies a cooldown of
RangeSplitSettleWindow seconds (default 10) to both new
descriptors. This prevents cascading splits before the new ranges
stabilize. An indivisible range receives a longer cooldown of
RangeSplitIndivisibleCooldown (default 5 minutes) to avoid
repeated sampling of a range that cannot split.
Load-based splitting reacts to sustained write pressure rather than
data volume. A separate polling loop runs every
RangeSplitLoadPollInterval seconds (default 5). The trigger
evaluates three conditions as an AND-predicate:
RangeSplitLoadThreshold.RangeSplitLoadMinQueueDepth (default
8).RangeSplitLoadMinCommitWaitMs is set, the partition’s
commit wait must reach or exceed that value.All three conditions must hold continuously for
RangeSplitLoadWindow seconds (default 15). A single poll
where any condition drops below its threshold resets the window.
A skew guard prevents splitting ranges where the key distribution is
heavily one-sided. If the LoadImbalanceMax ratio (default
0.8) shows that most keys would land in one half, the split is
refused.
Load-based splitting is disabled by default
(RangeSplitLoadThreshold = 0). Set the threshold to a
positive value to enable it. The threshold depends on the hardware: a
value that causes WAL queue saturation on one machine may be well within
capacity on another.
A splitLock semaphore serializes all split operations:
count-based, load-based, and manual. Only one split can run at a time
across all triggers. The semaphore is released when the split completes
or fails. Leadership loss clears all accumulated cooldown state.
The RangeMergeTrigger runs on the same timer as the
count-based split trigger (every RangeCollectionInterval
seconds). It scans all key-range key spaces for adjacent pairs where
both ranges contain fewer keys than RangeMergeMinSize
(default 10).
Two ranges are eligible for merging when all of the following conditions hold:
EndKey equals the
right range’s StartKey.RangeMergeMinSize.RangeSplitLoadThreshold. This load-warm guard
prevents merge-then-split oscillation.When the RangeMerger processes a merge candidate pair
[A,B)@P1 and [B,C)@P2, it follows this
sequence:
left.EndKey == right.StartKey.[B,C) at P2’s leader with a TTL of 30 seconds. This
prevents writes to the right range during the merge.RangeMapStore.QuiesceRangeAsync. This pauses routing to the
right range.[B,C) at an MVCC snapshot into partition P1.MutateAsync
call replaces both descriptors with one new descriptor:
[A,C)@P1 with a generation incremented by one.RemovePartitionAsync to
retire P2. If the removal call fails, the trigger retries it on the next
tick.The merge copies data in one direction only (right into left). Because both ranges are below the minimum size threshold, the copy window stays short. Orphan rows may remain on P2’s replicas until the partition is fully removed.
Operators can trigger splits and merges on demand without waiting for automatic thresholds.
KahunaSplitRangeResponse response =
await client.SplitRange("orders", "orders/5000");The call splits the range that covers splitKey at that
exact key boundary. The meta-partition leader must handle the request.
If the contacted node is not the leader, the response includes a
LeaderHint for the client to retry.
The response Status field carries one of these
values:
Succeeded: the split completed.NotLeader: forward to the leader hint.NoRange: no range covers the split key.InvalidSplitKey: the split key falls outside the range
or is empty.BelowMinRangeSize: one or both halves would be too
small.PartitionCreationFailed: the new partition could not be
created.TransferFailed: data transfer to the new partition
failed.QuiesceFailed: the quiesce step failed.CutoverFailed: the atomic descriptor swap failed.ConcurrentSplit: another split is already in
progress.Indeterminate: the outcome is unknown (check the range
map).On success, the response includes NewPartitionId (the
partition created for the right half) and NewGeneration
(the generation of the new descriptors).
KahunaMergeRangesResponse response = await client.MergeRanges();The call runs the same merge pass that the automatic trigger runs,
but on demand. It evaluates all key-range key spaces for adjacent pairs
below the merge threshold. The response includes a Merges
count.
In a multi-node cluster, Raft leadership tends to concentrate on nodes that recover first after a restart or that win elections more often because of timing. This concentration creates hot spots: one node handles all reads and writes for many partitions while other nodes sit idle. The leader balancer redistributes leadership across nodes.
The leader balancer is disabled by default. Enable it with:
--raft-enable-leader-balancer true
The balancer runs on the system-partition leader (partition 0) every
LeaderBalancerInterval milliseconds (default 30000, or 30
seconds).
The balancer uses a two-tier strategy. The count tier runs first. The load tier runs only when counts are already balanced.
Count tier. The balancer computes the ideal leader
count per node: total leaders divided by the number of live nodes. A
node is over-loaded when its leader count exceeds
ceil(ideal) + CountDeadband - 1. A node is under-loaded
when its leader count falls below floor(ideal). The
balancer picks the hottest partition from the most over-loaded node and
transfers its leadership to the most under-loaded node.
Load tier. When leader counts are balanced (no node
exceeds the over-loaded threshold), the balancer checks load imbalance.
If (maxLoad - minLoad) / maxLoad exceeds
LoadImbalanceThreshold (default 0.25), the balancer emits
count-neutral swaps: it moves one partition from a hot node to a cold
node and one from the cold node to the hot node. This keeps the leader
count unchanged while balancing actual workload. The balancer only emits
the swap if the resulting load spread is strictly smaller than the
current spread.
Each node reports its load through gossiped load reports at
LeaderBalancerReportInterval (default 5 seconds). Reports
older than LeaderBalancerReportTTL (default 20 seconds) are
discarded.
The load score for a node is:
Load = OpsWeight * OpsPerSecond + QueueWeight * QueueDepth
OpsWeight defaults to 1.0. QueueWeight
defaults to 0.5. Adjust these weights to emphasize throughput or queue
pressure depending on the workload.
Before the balancer emits a move, it checks several filters:
MinLeaderStabilityMs (default 5000) milliseconds.MoveCooldown, default 60 seconds).MaxConcurrentTransfers (default 2).MaxMovesPerPass (default 4).The system-partition leader sends a
TransferLeadershipSuggestionRequest to the current leader
of the target partition. The recipient validates the suggestion
(checking that it still leads the partition and that the target is a
viable candidate) and executes TransferLeadershipAsync. The
suggestion has a SuggestionTimeout of 15 seconds. If the
recipient does not confirm within that window, the move is abandoned and
the partition enters cooldown.
When a cluster runs with a replication factor greater than zero, the
placement rebalancer manages which nodes hold replicas for each
partition. The rebalancer runs on the system-partition leader every
PlacementPassInterval milliseconds (default 5000).
Each pass works through three stages in order:
Stage 1: Complete decommission drains. For each
cluster member with the Leaving role, check whether the
committed partition map still names that member’s endpoint. If no
replicas remain, commit the RemoveMember entry to finish
the decommission.
Stage 2: Drive in-flight transitions. The pass handles replicas that are mid-transition:
RemoveReplica
call.LearnerPromotionStableWindow, the pass promotes it to
voter.Stage 3: Plan rebalancing moves. This stage runs
only when EnablePlacementRebalancer is true. The pass
builds a PlacementView that includes all alive voters from
the committed roster, their zone assignments, and the current partition
map. It passes this view to the PlacementPlanner.
The PlacementPlanner evaluates ranges in four priority
tiers, from highest to lowest:
Repair under-replication. If a range has fewer
healthy voters than its replication factor, the planner adds a replica
on the least-loaded alive node. This tier uses the repair budget:
MaxConcurrentReplicaRepairs (default 3) minus the current
count of transitional replicas.
Trim over-replication. If a range has more voters than its replication factor, the planner removes the excess. It prefers victims whose zone is already covered by another voter in the same range. Among equal candidates, it picks non-leaders first, then the most-loaded node.
Repair zone-spread violations. If a range has two voters in the same zone while another zone has a free node, the planner adds a replica in the uncovered zone. The over-replication trim in the next pass removes the duplicate. This two-pass approach avoids a simultaneous add-and-remove that could temporarily reduce the voter count below the replication factor.
Balance replica-count skew. If a node holds more
replicas than ceil(ideal) + ReplicaCountDeadband (default
1), the planner moves one replica to the least-loaded node. This tier
uses the transfer budget: MaxConcurrentReplicaTransfers
(default 1) minus the current count of balance-move replicas.
The planner applies several stability guards:
MaxReplicaMovesPerPass (default 4) moves are
emitted per pass.Each node declares its zone through the --raft-zone
flag. The zone string is gossiped through load reports. The planner uses
zone information in two places:
Zone information from remote nodes arrives through gossip after the initial partition map is committed. Initial placement may be zone-blind for nodes that have not yet gossiped. The rebalancer converges to a zone-spread layout within a few passes.
By default, every partition uses the cluster-wide replication factor
set by --raft-replication-factor. Kahuna allows overriding
this value for individual partitions:
await raft.SetReplicationFactorAsync(partitionId: 5, replicationFactor: 5);The call is system-leader only. It commits the new replication factor
on the partition’s range entry in the partition map, increments the
range’s Generation and the map’s MapVersion,
and replicates the change through Raft.
The placement rebalancer reads each range’s
ReplicationFactor field. If the field is greater than zero,
it overrides the cluster-wide value. If the field is zero, the
cluster-wide value applies.
A common pattern is to set the cluster-wide replication factor to zero (full replication) and override it for specific high-throughput partitions that do not need copies on every node. The per-partition override reduces replication traffic for those partitions while the remaining partitions stay fully replicated.
The PartitionPlacementCoordinator on each Kahuna node
reacts to committed placement map changes. It maintains a
PartitionPlacementView that tracks which partitions this
node hosts.
When the hosted set changes:
PurgeUnhostedPartitionSafelyAsync. The purge removes all
backend rows, store slices, the durability floor, and the install marker
for the partition.On first startup, when the initial partition map arrives, the coordinator runs a cleanup pass. Any partition data on disk that does not appear in the committed map is purged. This handles two cases: a crash during a previous purge, and replicas removed while the node was offline.
| Flag | Default | Purpose |
|---|---|---|
--range-split-threshold |
1000 | Key count for count-based auto-split (0 disables) |
--range-split-min-range-size |
10 | Minimum keys each half must contain |
--range-split-settle-window |
10s | Post-split cooldown per descriptor |
--range-merge-min-size |
10 | Key count below which adjacent ranges merge (0 disables) |
--range-collection-interval |
60s | Interval between sampling passes |
--range-split-load-threshold |
0 | Ops/sec for load-based split (0 disables) |
--range-split-load-min-queue-depth |
8 | Minimum WAL queue depth for load-based split |
--range-split-load-window |
15s | Sustained-load window before split triggers |
--range-split-load-poll-interval |
5s | Load polling interval |
| Flag | Default | Purpose |
|---|---|---|
--raft-enable-leader-balancer |
false | Enable leader redistribution |
--raft-leader-balancer-interval |
30000 ms | Planning pass cadence |
--raft-leader-balancer-report-interval |
5000 ms | Load report gossip cadence |
--raft-leader-balancer-report-ttl |
20000 ms | Stale report cutoff |
--raft-count-deadband |
1 | Leader-count imbalance tolerance |
--raft-load-imbalance-threshold |
0.25 | Load skew ratio that triggers load-tier balancing |
--raft-min-leader-stability-ms |
5000 | Minimum leader tenure before a move |
--raft-move-cooldown |
60000 ms | Post-move cooldown per partition |
--raft-max-moves-per-pass |
4 | Maximum moves per planning pass |
--raft-max-concurrent-transfers |
2 | Maximum in-flight leadership transfers |
--raft-leader-balancer-ops-weight |
1.0 | Weight of ops/sec in load score |
--raft-leader-balancer-queue-weight |
0.5 | Weight of queue depth in load score |
--raft-suggestion-timeout |
15000 ms | Move confirmation timeout |
| Flag | Default | Purpose |
|---|---|---|
--raft-replication-factor |
0 | Cluster-wide voter count per range (0 = full replication) |
--raft-enable-placement-rebalancer |
false | Enable replica rebalancing |
--raft-placement-pass-interval |
5000 ms | Controller pass cadence |
--raft-max-replica-moves-per-pass |
4 | Maximum replica moves per pass |
--raft-max-concurrent-replica-transfers |
1 | Maximum balance moves in flight |
--raft-max-concurrent-replica-repairs |
3 | Maximum repair moves in flight |
--raft-replica-count-deadband |
1 | Replica-count skew tolerance |
--raft-zone |
(none) | Locality hint for zone-aware spreading |
Hot partition from concentrated writes. If one key
range receives most of the write traffic, load-based splitting can
divide the range. Enable it by setting
--range-split-load-threshold to a value below the point
where WAL queue depth grows. The trigger requires sustained pressure for
15 seconds before it acts. If the key distribution is too skewed for the
split to produce balanced halves (the LoadImbalanceMax
guard), the application must redesign its key scheme.
Indivisible range. A range that covers a single key
or whose sampled key count falls below
RangeSplitMinRangeSize on either side of the midpoint
cannot split. The trigger applies a 5-minute cooldown to avoid repeated
sampling. The operator must intervene by redesigning the key scheme to
distribute writes across more keys.
Split during active transaction. A range split
quiesces the range before the cutover step. Transactions with prepared
intents in the affected range are settled through
SettleSuppliedIntentsAsync before the cutover proceeds. An
undecided intent within its recovery deadline blocks the split until the
deadline passes or the coordinator commits. The transaction’s client
receives a generation-fence error after the split completes and must
retry with the new generation.
Merge-split oscillation. If the merge threshold and split threshold are set too close together, a range could merge below the merge threshold and then split above the split threshold in alternating cycles. The load-warm guard prevents merging ranges with active write traffic. Set the merge threshold well below the split threshold to create a stable band.
Leader balancer thrashing. If
MoveCooldown is too short or CountDeadband is
zero, the balancer can shuffle leadership back and forth between two
nodes on successive passes. Each transfer interrupts in-flight reads.
Keep the deadband at 1 or higher and the cooldown at 60 seconds or
longer.
Zone-blind initial placement. When a node first joins the cluster, its zone is not yet known to other nodes (the zone propagates through gossip). Initial replica placement may ignore zone constraints. The rebalancer corrects this within a few passes after gossip propagates. For clusters where zone spread is critical from the start, ensure all nodes are running and gossiping before registering key ranges.
A distributed system that replicates data across nodes can survive individual node failures. It cannot survive a bug that corrupts data on every replica, an operator who deletes the wrong key space, or a disk failure that takes out an entire availability zone. Backups provide the safety net for these scenarios. Point-in-time recovery (PITR) lets the operator restore data to any moment within the retention window, not just the latest state.
This chapter explains how Kahuna’s backup system works: how to take full and incremental backups, how coordinated cluster snapshots capture a consistent point across all partitions, how PITR replays write-ahead log (WAL) segments to reach an exact timestamp, and how retention policies and garbage collection keep the backup store manageable.
The chapter builds on two earlier chapters. Chapter 22 described the persistence backends and the background writer. Chapter 23 introduced the startup sequence and the PITR bootstrap path. This chapter covers the backup machinery that produces the artifacts those paths consume.
Kahuna supports two backup types: full and incremental.
A full backup captures a complete checkpoint of the storage backend at a specific point in time. The checkpoint is a self-contained image: restoring from a full backup requires no other artifact. Full backups are larger and slower to produce, but they are the foundation of every backup chain.
An incremental backup captures only the WAL entries committed since its parent backup. It is smaller and faster to produce, but it depends on its parent. Restoring from an incremental backup requires the entire chain back to the root full backup.
The BackupType enum has two values: Full
(0) and Incremental (1).
KahunaBackupInfo info = await client.TakeFullBackupAsync(cancellationToken);
Console.WriteLine($"Backup {info.BackupId} created at {info.CreatedAtUtc}");
Console.WriteLine($"Covers {info.PartitionCount} partitions");The response includes the BackupId (a GUID), the
CreatedAtUtc timestamp, the backup Type, the
PartitionCount, and the recoverable time window
(MinRecoverablePhysicalMs to
MaxRecoverablePhysicalMs).
The BackupDriver.RunFullAsync method orchestrates the
full backup through these steps:
Snapshot the topology. Compute an FNV-1a hash
over the sorted partition IDs, their range generations, and the
membership version. This hash is the TopologyGeneration. If
the topology changes during the backup, the driver detects the mismatch
and aborts.
Record committed indices. For each active, hosted partition, read the maximum committed WAL index before the flush. This establishes the coverage boundary.
Wait for the applied-HLC barrier. The background writer may have committed entries that are not yet flushed to the storage backend. The driver waits until the background writer’s maximum enqueued commit HLC reaches or exceeds the target for each partition. The timeout is 30 seconds. If any partition does not converge, the backup fails closed.
Flush persistence. Call the flush barrier to force the background writer to write all queued entries to the storage backend.
Compute the cut timestamp. The cut is the snapshot HLC (for coordinated backups) or the maximum committed HLC across all partitions.
Check the pruned-history floor. If MVCC revision pruning already passed the cut timestamp, the backup cannot produce a consistent image. The driver refuses the backup.
Acquire an MVCC snapshot hold. The driver acquires a hold at the cut timestamp with a lease of 600 seconds. This hold prevents the revision pruning system from deleting history that the backup needs. A background loop renews the hold at roughly one-third of the lease interval (every 200 seconds). If the renewal fails, the driver cancels all remaining work.
Create the checkpoint. Call the persistence
backend’s CreateCheckpointAsOf method. The backend produces
a directory of files that represent the state at the cut.
Hash and measure artifacts. Compute a SHA-256 hash of every checkpoint file and record its byte size. Set file permissions to owner-only (mode 0600 for files, 0700 for directories).
Build the manifest. Create a
BackupManifest with all metadata: backup ID, type, creation
time, format version, partition ranges (with WAL index and HLC coverage
per partition), base cut, topology generation, cluster and covered
partitions, checksums, and sizes.
Verify artifacts. Confirm that every file listed in the manifest exists, matches its checksum, and matches its recorded size.
Sign the manifest. If an HMAC key is configured,
compute an HMAC-SHA-256 tag over the manifest’s canonical payload and
store it in the Mac field.
Publish the manifest. Write the manifest to the backup catalog.
Release the snapshot hold. Best-effort release. If the release fails, the hold expires naturally after the lease period.
KahunaBackupInfo incremental = await client.TakeIncrementalBackupAsync(
parentBackupId: info.BackupId,
cancellationToken);The caller specifies the parent backup ID. The incremental backup captures all WAL entries committed after the parent’s coverage.
The BackupDriver.RunIncrementalAsync method follows
these steps:
Resolve the parent manifest. Load the parent from the catalog. Build high-water marks across the entire ancestor chain (not just the immediate parent). This transitive resolution ensures that the incremental covers exactly the gap since the last captured entry.
For each active, hosted partition:
fromIndex = parentHighWater.ToIndex + 1. For
partitions not covered by the parent (new partitions added after the
full backup), use the WAL compaction floor.fromIndex. If WAL compaction already discarded those
entries, the incremental cannot cover this partition. The driver returns
NeedsFullBackup.fromIndex to prevent
compaction during the capture. Re-check the compaction floor after the
hold to guard against a race.fromIndex to the end (or to the
snapshot timestamp for coordinated backups). Write the entries to a
partition_{id}.wal segment file. Compute a SHA-256 hash
during the write.Build, verify, sign, and publish the manifest, following the same steps as a full backup.
Release WAL retention holds.
If the incremental fails because WAL compaction passed the required
index (NeedsFullBackup), the BackupService
catches the failure and takes a full backup instead. The response marks
SubstitutionReason to inform the caller that a full backup
was taken in place of the requested incremental.
A full or incremental backup on a single node captures only the partitions that node hosts. In a multi-node cluster with a replication factor less than the cluster size, no single node holds every partition. A coordinated backup captures a consistent point across all partitions in the cluster.
The SnapshotCoordinator.ComputeSafeSnapshotTimeAsync
method computes the safe snapshot timestamp:
Query the cluster-wide minimum in-flight commit timestamp across all actor shards. This is the earliest HLC at which any currently executing transaction could commit.
If the minimum is greater than zero (transactions are in flight): set the snapshot timestamp to the HLC tick immediately before the minimum. Any in-flight transaction will commit at or after the minimum, so its effects will not appear in the snapshot. Any transaction that committed before the minimum is fully visible.
If the minimum is zero (the cluster is quiesced, no transactions in flight): set the snapshot timestamp to the maximum committed HLC across all partitions.
This calculation guarantees that the snapshot reflects a consistent cut: every committed transaction is either fully included or fully excluded. No partial transaction state crosses the cut boundary.
KahunaBackupInfo coordinated =
await client.TakeCoordinatedBackupAsync(cancellationToken);The call must reach the meta-partition leader (the backup
coordinator). If the contacted node is not the coordinator, the response
returns NotBackupCoordinator.
The coordinator captures its term at the start. After producing the backup artifacts, it verifies that it still holds leadership and that the term has not changed. If leadership moved during the backup, the artifacts are discarded. This fencing prevents two coordinators from producing conflicting snapshots.
Every backup produces a manifest that records everything needed to validate and restore from the backup. The manifest contains:
BackupId (GUID),
Type (Full or Incremental), CreatedAtUtc,
FormatVersion (currently 1).ParentBackupId (null for full
backups). This link forms the backup chain.PartitionRanges with
per-partition WAL coverage: FromIndex,
ToIndex, FromHlc, ToHlc,
ToTerm.BaseCut (the HLC at which
the full checkpoint was cut). Present only on full backups.ClusterSnapshotTime (the
safe HLC for coordinated backups), CoordinatorNode,
CoordinatorTerm.ClusterId,
StorageType, StorageRevision,
TopologyGeneration, ClusterPartitions,
CoveredPartitions.Checksums (SHA-256 hex per
artifact file), Sizes (byte length per artifact file).Mac (HMAC-SHA-256 hex
tag, or null if no key is configured).Kahuna signs backup manifests with HMAC-SHA-256 to detect tampering.
The BackupManifestMac class handles signing and
verification.
The MAC covers a deterministic serialization of the manifest fields: format version, backup ID, type, parent ID, creation time, cluster identity, coordinator node and term, storage type and revision, topology generation, base cut, cluster snapshot time, cluster and covered partition counts, partition ranges (sorted by partition ID), and files (sorted by key, with digest and size).
The sorted ordering ensures that the same manifest always produces the same payload, regardless of dictionary enumeration order.
Sign(manifest, key) computes the HMAC-SHA-256 tag and
stores it in the manifest’s Mac field.
Verify(manifest, key) recomputes the tag and compares it
with the stored value using
CryptographicOperations.FixedTimeEquals (constant-time
comparison to prevent timing attacks).
A missing MAC on a manifest fails verification closed: the manifest is rejected. If no MAC key is configured on the server, signing and verification are skipped entirely.
The key is loaded from a file specified by
--pitr-backup-mac-key-file. An empty or missing file causes
a startup error.
A backup chain is a sequence that starts with a full backup and
continues with zero or more incremental backups. Each incremental links
to its parent through the ParentBackupId field. The chain
forms a singly linked list from the newest incremental back to the root
full backup.
List<KahunaBackupInfo> chain =
await client.GetBackupChainAsync(leafBackupId, cancellationToken);The BackupCatalog.ResolveAndValidateAsync method
traverses parent links from the leaf to the root. It returns the full
ordered chain (root first, leaf last).
The catalog validates five properties:
ParentBackupId must match the previous entry’s
BackupId.ClusterId,
StorageRevision, and TopologyGeneration must
match across all entries (for non-null values). A chain that spans a
topology change is invalid.FromIndex must equal the parent’s
ToIndex + 1. A gap means WAL entries were lost between
backups. An overlap means entries would be replayed twice.HLC ordering is also checked: FromHlc must be less than
or equal to ToHlc within each partition range.
List<KahunaBackupInfo> backups =
await client.ListBackupsAsync(cancellationToken);
foreach (var b in backups)
{
Console.WriteLine(
$"{b.BackupId} {b.Type} {b.CreatedAtUtc} " +
$"partitions={b.PartitionCount}");
}The response includes metadata for every backup in the catalog. Each
entry carries IsInvalid and IsIncomplete flags
with an InvalidReason string for backups that failed
validation.
PITR restores data to an exact HLC timestamp by replaying the full checkpoint and then applying incremental WAL segments up to the target time.
await client.RestoreToAsync(
leafBackupId: chain.Last().BackupId,
targetDir: "/data/kahuna-restored",
targetTimeMs: 1719849600000, // Unix milliseconds
cancellationToken);The restore is an offline operation. The target node does not serve traffic during the restore.
The BackupService.RestoreToAsync method orchestrates the
full restore:
Resolve and validate the chain. Traverse parent links and validate all five chain properties.
Authenticate every manifest. If a MAC key is configured, verify the HMAC-SHA-256 tag on every manifest in the chain. A failed verification aborts the restore.
Verify every artifact. For each file listed in each manifest, verify the SHA-256 checksum, the byte size, that the path contains no directory traversal attacks, and that the path is not a symbolic link.
Validate partition coverage. Refuse partial-cluster chains (chains that do not cover all cluster partitions).
Validate the target timestamp. Use
BackupChainCoverage.Resolve to compute the exact
recoverable window. Refuse a target outside that window.
Destination safety checks. Refuse if the target directory is a symbolic link, is non-empty, overlaps with the backup store or live storage, or falls outside the restore root (if configured).
Copy the full checkpoint. Copy all checkpoint
files from the root full backup into a staging directory (a sibling of
the final target). If BackupRestoreThrottleMbps is set, the
copy respects the throughput budget.
Verify the staged copy. Re-check all checksums against the manifest.
Open the persistence backend. Initialize the storage backend at the staging directory.
Replay incremental segments. The
RestoreEngine.RestoreAsync method processes each
incremental manifest in chain order. For each partition in each
incremental:
LastModified timestamp). Entries with a
commit HLC after the target timestamp are skipped. Entries at or before
the target are applied.StoreKeyValues (an idempotent upsert by key and
revision).Flush. For memory backends, flush the merged result to disk.
Atomic publish. Move the staging directory to
the final target path with Directory.Move. This is an
atomic rename on the same filesystem.
Cleanup on failure. If any step fails, the staging directory is deleted or quarantined.
PITR images contain only key-value data. The
RestoreEngine filters entries by replication type and skips
everything except KeyValues. This means:
A new node can join an existing cluster by bootstrapping from a backup chain instead of receiving state transfers from live peers. This path is useful when adding a node to a cluster where the WAL on existing nodes has been compacted past the required entries.
Start the node with both --join-existing and
--pitr-bootstrap-from (the leaf backup GUID):
kahuna-server --join-existing \
--initial-cluster node1:8081,node2:8082 \
--pitr-backup-dir /backups/kahuna \
--pitr-bootstrap-from a1b2c3d4-...
The BootstrapHelper.BootstrapNodeAsync method:
now - pitrWindow - baseSnapshotInterval).RestoreEngine used by the offline restore.CommittedCheckpoint WAL entries for
each partition so that the Raft layer knows the node’s committed
index.AppendEntries messages for entries committed after
the bootstrap point.Without retention policies, the backup store grows without bound. Kahuna provides chain-aware retention planning and periodic garbage collection.
Three retention limits are available. All are optional. When multiple limits apply, the most restrictive one wins:
The newest chain is always kept, even if it alone exceeds
MaxTotalBytes.
Retention operates on whole chains, not individual backups. A leaf backup (one that is not referenced as a parent by any other backup) identifies a chain. If the leaf is marked for deletion, the entire chain is deleted. Deletion order is descendants before ancestors: incremental backups are removed before their parent full backup. This prevents orphaned incrementals from remaining after the full backup is gone.
A kept leaf pins its entire transitive parent closure. An intermediate incremental that is shared between two chains is retained as long as either chain survives.
Artifacts that exist on disk without a corresponding manifest are orphans. These arise from interrupted or failed backup operations. The garbage collector sweeps orphan artifacts but protects any backup that has a manifest file (including manifests with validation errors). This ensures that a corrupt but recoverable backup is never silently deleted.
The BackupGcReaperActor runs on a periodic timer:
BackupGcInterval seconds (default 3600, or 1 hour).GC is serialized with backup creation through a gate semaphore. This prevents a race where the GC deletes a manifest that a concurrent backup is about to reference as a parent.
Kahuna exposes backup operations through REST:
| Method | Path | Purpose |
|---|---|---|
| POST | /v1/backups/full |
Take a full backup |
| POST | /v1/backups/incremental |
Take an incremental backup (body: ParentBackupId) |
| POST | /v1/backups/coordinated |
Take a coordinated cluster backup |
| GET | /v1/backups |
List all backups |
| GET | /v1/backups/{id}/chain |
Resolve a chain from a leaf backup |
| POST | /v1/backups/validate-chain |
Validate a chain (body: LeafBackupId) |
| POST | /v1/restore |
Restore to a directory (body: LeafBackupId,
TargetDir, TargetTimeMs) |
| POST | /v1/backups/gc |
Run garbage collection (?dryRun=true for a dry-run
inventory) |
Every backup operation returns a KahunaBackupOutcome
that describes the result:
| Flag | Default | Purpose |
|---|---|---|
--pitr-window |
3600s (1h) | WAL retention window for PITR coverage |
--base-snapshot-interval |
1800s (30min) | Interval between automatic base checkpoints |
--pitr-backup-dir |
(none) | Root directory for backup artifacts and manifests |
--pitr-backup-target |
local | Storage target (local filesystem or registered provider) |
--pitr-backup-scratch-dir |
(none) | Local staging directory for non-local targets |
--pitr-backup-cluster-id |
(none) | Cluster identity string (gates chain resolution across clusters) |
--pitr-backup-mac-key-file |
(none) | Path to HMAC-SHA-256 key file for manifest authentication |
--backup-retention-max-chains |
0 | Maximum retained chains (0 = unbounded) |
--backup-retention-max-age |
0 | Maximum chain age in seconds (0 = unbounded) |
--backup-retention-max-bytes |
0 | Maximum total retained bytes (0 = unbounded) |
--backup-gc-interval |
3600s | Periodic GC cadence (0 disables periodic GC) |
--backup-restore-throttle-mbps |
0 | Restore copy throughput limit in MB/s (0 = unlimited) |
--pitr-restore-root |
(none) | Allowed root directory for remote restore operations |
--pitr-allow-unconfined-remote-restore |
false | Allow remote restores without a root directory |
--pitr-bootstrap-from |
(none) | Leaf backup GUID for PITR bootstrap on join |
Backup during high write load. A full backup acquires an MVCC snapshot hold and flushes the background writer. The flush blocks new writes from reaching the storage backend until the checkpoint completes. On a node with heavy write traffic, this pause can cause WAL queue buildup and increased commit latency. Schedule full backups during low-traffic windows. Incremental backups are lighter because they stream WAL segments without pausing the writer.
Backup chain corruption. If an artifact file is
corrupted on disk (bit rot, partial write, storage failure), the chain
validation detects the mismatch through SHA-256 verification. The backup
is marked as CorruptArtifact. The corrupted backup and all
its descendants are unusable. Take a new full backup to start a fresh
chain. The GC reaper does not delete corrupt backups automatically (the
manifest still exists), so the operator must delete them manually or
wait for retention policies to expire them.
Restore to a time before the pruned-history floor. MVCC revision pruning deletes old versions of key-value entries. If the target PITR timestamp falls before the pruned-history floor, the full backup checkpoint may already be missing the revisions needed for a consistent restore. The backup driver checks the floor before creating the checkpoint and refuses the backup if pruning already passed the cut. For the restore path, the chain coverage validator refuses a target outside the recoverable window.
WAL compaction breaks incremental chain. If the WAL
is compacted past the high-water mark of the parent backup, the
incremental cannot read the required entries. The driver returns
NeedsFullBackup. The BackupService catches
this outcome and takes a full backup as a fallback. To prevent this, set
--pitr-window to a value longer than the expected interval
between backups.
Topology change during backup. If a node joins, leaves, or if a range splits during the backup, the topology generation changes. The driver detects the mismatch and aborts. The caller can retry. For coordinated backups, the coordinator also verifies that its leadership term has not changed.
MAC key mismatch. If the HMAC key on the restoring node differs from the key used to sign the manifest, verification fails and the restore is refused. Keep the MAC key consistent across all nodes and across backup and restore operations. Store the key file outside the backup directory.
A Kahuna cluster that runs well in development may degrade under production load. A single hot partition, an undersized cache, or an aggressive WAL compaction policy can turn a responsive system into an unresponsive one. Operators need metrics to detect problems before users notice them, diagnostic tools to locate the bottleneck, and tuning parameters to resolve it.
This chapter covers three topics: the metrics Kahuna exposes, the benchmark tool for measuring baseline performance, and the tuning knobs that control the IO scheduler, write batching, caching, memory budgets, and transaction admission.
The chapter builds on three earlier chapters. Chapter 14 introduced the architecture. Chapter 22 described the persistence backends and the background writer. Chapter 24 covered deployment modes. Observability and tuning operate on the runtime behavior of those subsystems.
Kahuna emits all metrics through
System.Diagnostics.Metrics with a meter named
"Kahuna" (version "1.0"). Instrument names
follow the OpenTelemetry dot-separated lowercase convention:
kahuna.durable_tx.finalize_prepare_ms,
kahuna.kv.write.batches, and so on.
Kahuna does not bundle a Prometheus or OpenTelemetry exporter. The metrics are standard .NET instruments that any subscriber can consume:
dotnet-counters monitor --process-id <pid> Kahuna.EmbeddedKahunaNode, the host application owns the DI
container and can register any exporter.Prometheus exporters translate dots to underscores automatically, so
kahuna.kv.write.batches becomes
kahuna_kv_write_batches in Prometheus.
Kahuna defines nine metrics classes. This section groups them by subsystem and highlights the instruments most useful for production monitoring.
The PartitionWriteAggregatorMetrics class tracks the
write batching pipeline:
| Instrument | Type | Name |
|---|---|---|
| Counter | kahuna.kv.write.admitted |
Direct writes admitted into the queue |
| Counter | kahuna.kv.write.rejections |
Writes rejected, tagged by reason |
| Counter | kahuna.kv.write.batches |
Raft batches dispatched |
| Counter | kahuna.kv.write.entries |
Total log entries dispatched |
| Histogram | kahuna.kv.write.batch_items |
Entries per batch |
| Histogram | kahuna.kv.write.batch_bytes |
Bytes per batch |
| Histogram | kahuna.kv.write.queue_age |
Oldest item age in each dispatched batch (ms) |
| Histogram | kahuna.kv.write.raft_duration |
Raft call duration (ms) |
| Gauge | kahuna.kv.write.queued_items |
Items admitted but not yet completed |
| Gauge | kahuna.kv.write.queued_bytes |
Bytes admitted but not yet completed |
| Gauge | kahuna.kv.write.in_flight_partitions |
Partitions with an in-flight batch |
What to watch. A rising queued_items
gauge means writes are arriving faster than Raft can commit them. A high
queue_age histogram means items wait a long time before
dispatch. Rising rejections with reason
queue_full means the node is shedding load.
Rejection reasons include queue_full,
oversized, inbox_full, stopping,
fence_stale, and queue_expired.
The DurableTransactionMetrics class tracks the
transaction lifecycle:
| Instrument | Type | Name |
|---|---|---|
| Counter | kahuna.durable_tx.one_phase_commits |
One-phase fast-path commits |
| Counter | kahuna.durable_tx.one_phase_fallbacks |
One-phase eligible transactions that fell back to 2PC |
| Counter | kahuna.durable_tx.admission_rejections |
Transactions refused at the outstanding cap |
| Counter | kahuna.durable_tx.deadline_expiry_aborts |
Recovery aborts from deadline expiry |
| Counter | kahuna.durable_tx.late_commit_rejections |
Commits rejected past the decision deadline |
| Counter | kahuna.durable_tx.gc_records_reclaimed |
Transaction records removed by GC |
| Histogram | kahuna.durable_tx.finalize_prepare_ms |
Prepare-stage wall time (ms) |
| Histogram | kahuna.durable_tx.finalize_validate_ms |
Read-set validation wall time (ms) |
| Histogram | kahuna.durable_tx.finalize_decision_ms |
Decision-stage wall time (ms) |
| Histogram | kahuna.durable_tx.finalize_read_set_keys |
Read-set size per finalize |
| Histogram | kahuna.durable_tx.decision_deadline_margin_ms |
Time remaining before deadline at decision (ms) |
| Gauge | kahuna.durable_tx.outstanding |
Outstanding durable transactions |
| Gauge | kahuna.durable_tx.resident_records |
Resident transaction records |
| Gauge | kahuna.durable_tx.resident_prepared_intents |
Resident prepared intents |
What to watch. A high outstanding gauge
indicates transactions that have not finished. Rising
admission_rejections means the cap is too low for the
workload. A shrinking decision_deadline_margin_ms means
transactions are close to their deadline. If
deadline_expiry_aborts is non-zero, some transactions are
timing out before they can commit.
The TransactionPriorityMetrics class tracks the
admission control gates. All gauges are tagged by gate
(script or session) and priority:
| Instrument | Type | Name |
|---|---|---|
| Gauge | kahuna.tx_admission.in_flight |
Transactions holding an admission slot |
| Gauge | kahuna.tx_admission.queued |
Transactions waiting for a slot |
| Gauge | kahuna.tx_admission.max_queue_depth |
High-water mark of waiters |
| Gauge | kahuna.tx_admission.admitted |
Total transactions admitted since startup |
| Gauge | kahuna.tx_admission.aged_promotions |
Waiters promoted by anti-starvation aging |
| Gauge | kahuna.tx_admission.abandoned_while_waiting |
Waiters that timed out before admission |
| Gauge | kahuna.tx_admission.rejected_queue_full |
Requests refused because the queue was full |
What to watch. A non-zero queued gauge
means the admission gate is deferring work. A rising
rejected_queue_full means the node is shedding
transactions. A high aged_promotions count means
low-priority work is aging into higher priority slots.
The RangeSplitMetrics class tracks automatic range
operations:
| Instrument | Type | Name |
|---|---|---|
| Counter | kahuna.range.splits |
Total splits committed (count-based and load-based) |
| Counter | kahuna.range.split.indivisible_refusals |
Splits refused because no good split key exists |
| Counter | kahuna.range.split.settle_skips |
Descriptors skipped because they are in a post-split settle window |
| Counter | kahuna.range.split.no_relief_skips |
Load-splits skipped because no peer node can host the child |
| Counter | kahuna.range.merge.warm_skips |
Merge candidates skipped because the partition is warm |
What to watch. Rising
indivisible_refusals means a range cannot split because
writes concentrate on too few keys. Rising no_relief_skips
means the cluster has no capacity to absorb new partitions after a
split.
The SnapshotFloorMetrics class tracks MVCC snapshot
holds:
| Instrument | Type | Name |
|---|---|---|
| Counter | kahuna.snapshot_floor.missing_protected_version_total |
Floor-protected revisions scheduled for trimming (must stay 0) |
| Counter | kahuna.snapshot_floor.prune_skipped_unconfirmed_total |
Prune cycles skipped because meta-partition catch-up is unconfirmed |
| Gauge | kahuna.snapshot_floor.live_holds |
Live MVCC snapshot holds |
| Gauge | kahuna.snapshot_floor.effective_floor_ms |
Effective floor timestamp (ms) |
What to watch.
missing_protected_version_total must stay at zero. A
non-zero value means the pruning system attempted to delete a revision
that was still protected by a snapshot hold. live_holds
should stay low; a growing count means snapshot holds are not being
released.
The CollectMetrics class tracks the per-partition
key-value cache:
| Instrument | Type | Name |
|---|---|---|
| Counter | kahuna.collect.cycles |
Eviction cycles executed |
| Histogram | kahuna.collect.cycle.duration |
Duration of each cycle (ms) |
| Counter | kahuna.collect.evicted |
Entries evicted, tagged by reason |
| Counter | kahuna.collect.inspected |
Entries inspected during scan |
| Counter | kahuna.collect.backlogged |
Cycles that carried work past the eviction budget |
Eviction reasons: tombstone (deleted entries drained),
expiry (TTL elapsed), lru (evicted under
budget pressure), idle (untouched past the idle TTL).
What to watch. A sustained high rate of
lru evictions means the working set exceeds the cache
budget. The partition re-reads evicted entries from the storage backend,
which increases read latency and IO load.
The PlacementMetrics class tracks replica placement
changes:
| Instrument | Type | Name |
|---|---|---|
| Counter | kahuna.placement.replicas_gained |
Partitions this node started hosting |
| Counter | kahuna.placement.replicas_lost |
Partitions this node stopped hosting |
| Counter | kahuna.placement.forwards_resolved |
Non-hosted operations that found a forward target |
| Counter | kahuna.placement.forwards_unresolved |
Non-hosted operations with no forward target |
The BackupIoMetrics and BackupGcMetrics
classes track backup operations:
| Instrument | Type | Name |
|---|---|---|
| Counter | kahuna.backup.operations |
Backups completed |
| Counter | kahuna.backup.failures |
Backup attempts that failed |
| Counter | kahuna.backup.bytes |
Artifact bytes written |
| Histogram | kahuna.backup.duration_ms |
Backup duration (ms) |
| Counter | kahuna.restore.operations |
Restores completed |
| Counter | kahuna.restore.entries_applied |
WAL entries applied during restore |
| Counter | kahuna.backup.gc.orphans_reclaimed |
Orphaned artifacts reclaimed by GC |
| Counter | kahuna.backup.gc.retention_deletions |
Backups deleted by retention policy |
Kahuna ships a benchmark tool (kahuna-bench) for
measuring baseline performance and validating tuning changes. The tool
supports multiple workloads, two execution modes, and correct latency
measurement.
The tool supports ten workload types:
| Workload | Description |
|---|---|
set |
Write-only: set keys with random values |
get |
Read-only: get keys (after seeding) |
mixed |
Read-write: mix of get and set at a configurable ratio |
delete |
Delete-only: delete keys (after seeding) |
set-many |
Batch writes: set keys in batches |
delete-many |
Batch deletes: delete keys in batches |
txn |
Interactive transactions: read and write multiple keys in a transaction |
lock |
Distributed locks: acquire and release locks |
sequence |
Distributed sequences: reserve sequence values |
script |
Script execution: run a user-supplied .4gl script |
Closed-loop (default, --rate 0). A
fixed number of concurrent workers (--concurrency, default
64) fire requests as fast as possible. Each worker waits for a response
before sending the next request. This mode measures maximum throughput
but underreports tail latency because slow requests naturally reduce the
request rate.
Open-loop (--rate N). A producer emits
N tickets per second through a bounded channel. Workers consume tickets
and measure latency from the intended start timestamp, not the
actual start. When the server is slow, tickets queue up in the channel.
Each ticket’s measured latency includes the queuing delay. This
correctly accounts for coordinated omission: a slow response at time T
delays all subsequent requests, and the benchmark counts that delay in
every affected measurement.
The tool uses HdrHistogram with 3 significant digits for latency
recording. Pacing uses a hybrid approach: Task.Delay for
the coarse portion and Thread.SpinWait for sub-millisecond
precision.
Each benchmark run has three phases:
--warmup
seconds (default 5). Samples from this phase are discarded. This allows
JIT compilation, connection pooling, and cache warming to
stabilize.--duration seconds (default 30). Only samples from this
phase appear in the results.The default console output shows a table with columns: Operation,
Count, req/s, p50, p90, p95, p99, p99.9, max, mean, and error/miss
counts. Alternative output formats are json and
csv (--format).
| Option | Default | Purpose |
|---|---|---|
--workload |
mixed | Workload type |
--duration |
30 | Measurement window (seconds) |
--warmup |
5 | Warmup seconds (discarded) |
--concurrency |
64 | Concurrent workers |
--rate |
0 | Target requests per second (0 = closed-loop) |
--key-space |
10000 | Number of distinct keys |
--key-prefix |
“bench:” | Key prefix |
--value-size |
128 | Value payload bytes |
--read-pct |
50 | Read percentage for the mixed workload |
--batch-size |
100 | Keys per set-many or delete-many batch |
--keys-per-txn |
4 | Keys per interactive transaction |
--txn-locking |
pessimistic | Transaction locking mode (pessimistic or optimistic) |
--durability |
persistent | Durability level (persistent or ephemeral) |
--script |
(none) | Path to a .4gl script for the script workload |
--timeout |
10 | Per-request timeout (seconds) |
--format |
console | Output format (console, json, or csv) |
--seed |
0 | RNG seed (0 = time-based) |
kahuna-bench \
--workload mixed \
--read-pct 80 \
--concurrency 128 \
--duration 60 \
--key-space 100000 \
--value-size 256 \
--format consoleThis runs a read-heavy mixed workload (80% reads, 20% writes) with 128 concurrent workers for 60 seconds across 100,000 keys.
kahuna-bench \
--workload get \
--rate 5000 \
--concurrency 128 \
--duration 30 \
--key-space 50000This measures read latency at a fixed rate of 5,000 requests per second. The open-loop mode reports accurate tail latency even when the server cannot sustain the target rate.
The FairReadScheduler manages read operations across
partitions. It provides fair scheduling so that a read-heavy partition
cannot starve reads to other partitions.
The scheduler maintains a per-partition FIFO queue. Worker threads
drain requests across partitions in a fair rotation. Each scheduling
cycle drains up to 64 operations (the MaxBatchSize). The
scheduler supports read coalescing: multiple reads to the same partition
can be batched into a single backend call (for example, RocksDB’s
MultiGet).
When a partition’s queue reaches the configured depth limit, new read
requests receive a ReadBackpressureExceededException. This
exception signals the caller to retry after a backoff.
| Flag | Default | Purpose |
|---|---|---|
--backend-read-io-threads |
8 | Number of read worker threads |
--backend-write-io-threads |
1 | Number of write worker threads |
--backend-read-queue-depth |
4096 | Maximum queued reads per partition |
When to adjust. Increase
--backend-read-io-threads if read latency is high and the
CPU is not saturated. The default of 8 is suitable for most deployments.
Increase --backend-read-queue-depth if you see
ReadBackpressureExceededException errors during legitimate
traffic spikes (not during an overload that should be shed).
The PartitionWriteAggregator batches writes to reduce
the number of Raft proposals. It uses 8 single-threaded lanes. Writes to
the same partition share a lane and accumulate into batches.
When a write arrives and no batch is in flight for that partition,
the aggregator waits LingerMs milliseconds (default 1)
before dispatching the batch. This short delay allows more writes to
accumulate, which produces larger batches and fewer Raft round-trips. A
linger of 0 dispatches immediately (lowest latency, smallest batches). A
linger of 2 to 5 ms produces larger batches under load but adds latency
to every write.
Each batch is capped by item count (MaxBatchItems,
default 512) and byte size (MaxBatchBytes, default 4 MiB).
When either limit is reached, the batch dispatches immediately without
waiting for the linger delay.
Per-partition queue limits prevent a single partition from consuming all node memory:
| Limit | Default | Purpose |
|---|---|---|
MaxQueuedItemsPerPartition |
8192 | Maximum queued items per partition |
MaxQueuedBytesPerPartition |
32 MiB | Maximum queued bytes per partition |
MaxQueuedItemsGlobal |
131,072 | Maximum queued items across all partitions |
MaxQueuedBytesGlobal |
512 MiB | Maximum queued bytes across all partitions |
A TerminalReserveItemsPerPartition of 256 items is
reserved for transaction settlement. This ensures that a saturated
partition can still admit commit and settle operations.
Items that sit in the queue longer than MaxQueueDelayMs
(default 1000 ms) are released with a MustRetry status. The
Raft round-trip deadline is BatchExecutionTimeoutMs
(default 30,000 ms).
| Flag | Default | Purpose |
|---|---|---|
--kv-write-linger-ms |
1 | Linger delay before batch dispatch (ms) |
--kv-write-max-batch-items |
512 | Maximum entries per batch |
--kv-write-max-batch-bytes |
4194304 | Maximum bytes per batch (4 MiB) |
Each partition maintains an in-memory cache of key-value entries. The cache serves reads without touching the storage backend. Cache hits avoid disk IO entirely.
| Parameter | Default | Purpose |
|---|---|---|
MaxEntriesPerActor |
50,000 | Maximum cached entries per partition |
MaxBytesPerActor |
256 MiB | Maximum cached bytes per partition |
CacheEntryTtl |
1800s (30 min) | Idle TTL before eviction |
The cache evicts entries for four reasons:
CacheEntryTtl seconds.When to adjust. If the
kahuna.collect.evicted counter with reason lru
is high, the working set exceeds the cache budget. Increase
MaxEntriesPerActor or MaxBytesPerActor. If the
node has limited memory, consider reducing the cache budget and
accepting higher read latency, or add more nodes to distribute the
working set.
When Kahuna uses RocksDB as its storage backend, each partition opens its own RocksDB instance. Without coordination, each instance allocates its own block cache and memtable buffers. On a node with many partitions, the total memory usage can exceed the available RAM.
The shared memory feature pools block cache and memtable memory across all RocksDB instances on the node.
RocksDbSharedResources.CreateWithUnifiedBudget creates
one LRU block cache and one WriteBufferManager. The
memtable sub-budget is cost-charged to the block cache, so both share a
single memory bound. The cache uses soft (non-strict) mode to prevent
write errors when memtables flush. The WriteBufferManager
runs with allow_stall = false to prevent cross-database
flush coupling.
| Flag | Default | Purpose |
|---|---|---|
--rocksdb-shared-memory |
false | Enable shared memory budget |
--rocksdb-shared-memory-budget-mb |
320 | Total shared block cache (MB) |
--rocksdb-shared-memtable-budget-mb |
128 | Memtable sub-budget within the cache (MB) |
When to enable. Enable shared memory on any node with more than a few partitions using RocksDB. Without it, each instance allocates independently, and total memory use grows with partition count. A 320 MB shared budget with a 128 MB memtable sub-budget is a reasonable starting point for nodes with 8 to 32 partitions. Increase the budget for larger partition counts or for nodes with more available RAM.
The MemtableMemoryUsage property on the shared resources
object reports live memtable memory for monitoring.
Under high concurrency, too many simultaneous transactions can exhaust resources: each transaction holds read-set entries, write intents, and an admission slot. Admission control limits the number of concurrent transactions and prioritizes important work.
Kahuna defines five transaction priority levels:
| Level | Value | Purpose |
|---|---|---|
Background |
0 | Bulk or deferrable work |
Low |
1 | Lower than ordinary but latency-relevant |
Normal |
2 | Default for all transactions |
High |
3 | Latency-critical application work |
Critical |
4 | Must not be starved; never demoted |
Unknown priority values are normalized to Normal.
Kahuna runs two independent admission gates: one for script transactions and one for interactive sessions. The gates are separate because interactive sessions hold admission slots much longer than scripts (the session stays open for multiple round-trips).
When the transaction count is below the configured ceiling, admission is transparent: requests pass through with no delay. When the count reaches the ceiling, new requests enter a priority queue. The gate dispatches waiting requests in priority order (highest first, FIFO within the same priority).
A configurable number of slots from the concurrency ceiling are
reserved for High and Critical transactions.
Lower-priority transactions cannot use reserved slots, even when the
total count is below the ceiling. At least one slot always remains
available for lower priorities.
A low-priority transaction that waits longer than
AgingThresholdMs (default 1000 ms) gains one effective
priority level. This prevents indefinite starvation under sustained
high-priority load. A Background transaction that waits 1
second becomes effectively Low. After another second, it
becomes effectively Normal. Critical
transactions are never demoted.
The maxQueued parameter limits the total number of
waiting transactions. Beyond this limit, new callers receive a null
result (retryable load shedding). The rejected_queue_full
metric tracks these rejections.
| Flag | Default | Purpose |
|---|---|---|
--max-concurrent-transactions |
0 | Script transaction concurrency ceiling (0 = no limit) |
--max-concurrent-sessions |
0 | Interactive session concurrency ceiling (0 = no limit) |
--transaction-priority-reserved-slots |
0 | Slots reserved for High and Critical |
--transaction-priority-aging-threshold |
1000 | Wait time before aging one priority level (ms) |
The durable finalizer has its own cap:
DurableDecisionOutstandingMax (default 100,000), which
limits the number of outstanding durable finalize operations.
kahuna.kv.write.raft_duration histogram. If Raft
call duration is high, the bottleneck is consensus.kahuna.kv.write.queue_age histogram. If queue age
is high but Raft duration is normal, writes are queuing before dispatch.
Increase --kv-write-max-batch-items or reduce
--kv-write-linger-ms.kahuna.kv.write.queued_items gauge. If the queue
is growing, writes arrive faster than Raft can commit. Add more nodes,
split hot ranges, or reduce write volume.kahuna.collect.evicted counter with reason
lru. If LRU evictions are frequent, the working set exceeds
the cache. Increase MaxEntriesPerActor or
MaxBytesPerActor.ReadBackpressureExceededException errors. If
present, the IO scheduler’s queue is full. Increase
--backend-read-queue-depth or
--backend-read-io-threads.kahuna.durable_tx.decision_deadline_margin_ms
histogram. A shrinking margin means transactions are close to their
deadline.kahuna.durable_tx.finalize_prepare_ms and
kahuna.durable_tx.finalize_validate_ms histograms. A slow
prepare or validate stage points to write contention or a large read
set.kahuna.tx_admission.queued gauge. Non-zero values
mean transactions are waiting for admission. Increase
--max-concurrent-transactions or
--max-concurrent-sessions, or add priority configuration to
ensure critical work gets through.--kv-write-linger-ms to 2 to 5 ms. Larger
batches reduce Raft round-trips.--kv-write-max-batch-items to 1024 or higher
if the workload produces large bursts.--backend-read-io-threads to match the number
of storage devices.--raft-enable-leader-balancer true) to spread leadership
across nodes.--kv-write-linger-ms to 0. Batches dispatch
immediately.--kv-write-max-batch-items at the default or
lower. Smaller batches commit faster.MaxEntriesPerActor,
MaxBytesPerActor) to reduce cache misses.--backend-read-io-threads high enough that reads
never queue.Read backpressure. When the per-partition read queue
exceeds --backend-read-queue-depth, the IO scheduler throws
ReadBackpressureExceededException. The client receives a
retryable error. This prevents unbounded memory growth from queued
reads. If this error appears during legitimate traffic, increase the
queue depth or add read threads. If the error appears during a traffic
spike that exceeds the node’s capacity, the backpressure is working as
designed: it sheds load to protect the node.
Cache eviction storms. When the working set exceeds the cache budget, the eviction cycle runs frequently and evicts entries that are needed again shortly. Each eviction forces a backend read. Under sustained pressure, the cache becomes ineffective and read latency approaches raw backend latency. Increase the cache budget, reduce the working set (by adding partitions or nodes), or accept the latency cost.
Admission refused under load. When
--max-concurrent-transactions is set too low for the
workload, the admission gate rejects excess transactions. The
rejected_queue_full metric rises. Applications receive
retryable errors. Set the ceiling based on the node’s memory and CPU
capacity. Use priority levels and reserved slots to ensure critical
transactions get through even when the gate is saturated.
Hot partition. All writes concentrate on one range,
and one node handles all the write traffic for that range. The write
queue on that partition grows while other partitions sit idle. The
queued_items gauge shows a spike on one partition.
Solutions: enable load-based range splitting
(--range-split-load-threshold), enable the leader balancer
to spread leadership, or redesign the key scheme to distribute
writes.
A unit test verifies that a function returns the correct value. An integration test verifies that two components work together. Neither verifies that a distributed system keeps its safety properties when a network partition splits the cluster, a process crashes mid-replication, or a clock drifts between nodes. Kahuna uses three layers of testing to cover this gap: embedded cluster tests that run in-process, end-to-end tests that exercise the REST API, and Jepsen tests that inject real faults and check formal correctness properties against the resulting histories.
This chapter builds on Chapter 17, which introduced Raft consensus, leader election, and the quorum rule. The findings described here are drawn from the kahuna-jepsen repository and its FINDINGS.md document.
Kahuna’s test suite includes an embedded cluster that runs three Raft
nodes inside a single process. No Docker containers, no network sockets,
no disk writes. The BaseCluster abstract class in
Kahuna.Server.Tests provides the scaffolding.
BaseCluster assembles a three-node cluster using two
in-memory communication layers. InMemoryCommunication
carries Raft messages (votes, appends, heartbeats) between the nodes.
MemoryInterNodeCommunication carries inter-node requests
(leader forwarding, remote lag queries). Both layers route messages by
endpoint string and deliver them as direct method calls inside the same
process.
Each node gets a distinct election timeout seed. The base seed is
91000 milliseconds, and each node adds its NodeId (0, 1, or
2) to produce seeds of 91000, 91001, and 91002. This determinism ensures
that node 0 wins the first election in every test run. A
TimingScale multiplier (read from the
KAHUNA_TEST_TIMING_SCALE environment variable) stretches
all timeouts for slow CI runners.
Storage is entirely in memory. Each node uses a memory-backed write-ahead log and a memory-backed state store. The default configuration creates three data partitions plus one system partition. No RocksDB instance is opened and no file touches disk.
BaseCluster exposes several factory methods for
different test shapes:
EmbeddedKahunaNode instances that
share an InMemoryCommunication bus and a
MemoryInterNodeCommunication bus.BaseCluster provides four helper methods that handle the
timing and retry challenges of testing a consensus system:
MustRetry (because leadership
moved or the node is not initialized), the helper retries after a short
delay. This is the correct way to handle transient unavailability in a
test.Each node in the cluster is an instance of
EmbeddedKahunaNode. This class implements
IAsyncDisposable and provides the full Kahuna server stack
in a single object.
EmbeddedKahunaNode has two constructors. The standalone
constructor creates a single-node cluster with phantom witnesses that
simulate quorum for a one-node deployment. The cluster constructor takes
external ICommunication,
IInterNodeCommunication, and IDiscovery
implementations. BaseCluster uses the cluster constructor
and injects the in-memory layers.
The node exposes two properties: Kahuna (the
IKahuna interface for client operations) and
Raft (the IRaft interface for Raft-level
queries such as leader identity and committed indexes).
StartAsync joins the cluster and waits until every
hosted partition has a leader. FlushAsync forces the
background writer to drain its queue. DisposeAsync follows
a careful teardown order: drain pending writes, dispose the Raft layer,
shut down actors gracefully, and dispose shared RocksDB resources
last.
Embedded tests cover the fast, deterministic cases: key-value read and write, lock acquire and release, sequencer allocation, transaction commit and abort, and the interactions between these operations under stable leadership. They do not inject network faults, do not simulate process crashes, and do not test clock skew. For those properties, Kahuna uses Jepsen.
Jepsen is a fault-injection framework for distributed systems, written in Clojure by Kyle Kingsbury. It runs a cluster of real nodes (typically five), generates client operations, injects faults (called nemeses), records every operation and its result into a history, and then checks that history against a formal correctness model.
Kahuna maintains a dedicated Jepsen test suite in the kahuna-jepsen repository. The suite runs five Kahuna nodes in Docker containers, each built from the same .NET binary that ships in production. The test infrastructure requires Docker with at least 2 CPUs, the .NET 10 SDK, and Leiningen with a JDK for the Clojure harness.
A test run follows three steps:
scripts/build-tarball.sh.docker/up.sh.lein run test, passing the workload
name, fault types, and timing parameters.A typical invocation:
lein run test --workload register --faults partition \
--nodes n1,n2,n3,n4,n5 --time-limit 180 --concurrency 9 --rate 10
The suite defines five workloads. Each tests a different Kahuna primitive against a different correctness property.
Register. Tests the key-value store as a linearizable CAS (compare-and-swap) register. Clients perform reads, writes, and compare-and-swap operations on a small set of keys. The Knossos checker verifies linearizability: every operation must appear to take effect at a single instant between its invocation and its completion, and the resulting sequence must be consistent with a single serial execution.
Lock. Tests the distributed lock with two properties. Mutual exclusion: no two processes may hold the same lock at the same time. Fencing-token monotonicity: every successive lock acquisition must receive a strictly higher fencing token than the previous one. The checker is lease-aware, meaning it accounts for the lock’s configured expiry when computing hold windows.
Append. Tests interactive transactions using Elle’s list-append model. Each transaction reads one or more lists and appends a unique value to one or more lists. Elle constructs a dependency graph from the committed history and checks for cycles that would violate serializability. This workload found four real bugs in Kahuna’s transaction path.
Sequencer. Tests the distributed sequencer with three properties. Uniqueness: no identifier is handed out twice. Range integrity: every identifier falls within the allocated range. Idempotent replay: a retried allocation returns the same result.
Snapshot. Tests pinned MVCC snapshots. A client opens a snapshot hold at a given timestamp, reads several keys, and verifies that the snapshot never changes its answer. The checker compares each read against the snapshot’s pinned revision and fails if any read returns a different value.
The suite injects seven types of faults:
Beyond the default five-node, single-replica configuration, the suite supports two specialized profiles:
Replication-factor profile. Uses six nodes (to allow
decommission of one without losing quorum) and sets a replication factor
of 3. The placement nemesis moves replicas between nodes while the
workload runs. This profile found that the placement rebalancer planned
no moves at all (fixed in kahuna 09c99a1).
Key-range profile. Registers the key space for ordered routing so that keys are served by their position in the key order rather than by hash. The range nemesis forces splits and merges. This profile found a read-skew anomaly caused by unsettled intents during a range split’s data copy.
Each workload uses a checker matched to the property it tests:
The last two checkers illustrate an important principle: a vacuity
gate. A test that exercises no faults and observes no movement cannot
claim that the system handles faults and movement correctly. These
checkers return :unknown rather than
:valid? true when their preconditions are not met.
The kahuna-jepsen suite found real bugs in Kahuna and in its consensus library, Kommander. This section describes the most significant findings, what they taught about distributed systems, and how they were fixed.
Workload: register, with network partitions.
The bug. A node isolated from the majority kept
answering reads with its last-known value. The node knew it could not
replicate (writes correctly returned MustRetry), but reads
were served from local state without a quorum check.
The asymmetry was the diagnostic clue. On the same isolated node,
writes refused with MustRetry because the node could not
reach a quorum. Reads served from local state because
KeyValueLocator.LocateAndTryGetValue checked
raft.AmILeader(), a local belief that remained true until
the node stepped down.
A concrete trace from a failing run:
20:00:04.47 proc 244 (n2) :info :write [19 0] :must-retry
20:00:06.44 proc 81 (n1) :ok :write [19 1]
20:00:06.51 proc 253 (n2) :ok :read [19 0] ← stale
20:00:09.46 proc 253 (n2) :ok :read [19 0] ← still stale, 11 s in
Node n2’s value froze at the value committed just before the partition. The rest of the cluster moved on. This reproduced in 1 of 3 runs with different partition shapes and keys.
The fix. ConfirmLeadershipForRead
replaced the local AmILeader check with a quorum-confirmed
Raft read-index. When the node cannot confirm its leadership with a
majority, it returns MustRetry. This fix was applied across
the key-value, lock, and sequencer locators.
Verified: 8 of 8 clean runs against a prior 1-in-3 reproduction rate.
The lesson. A local leadership belief is not the same as confirmed leadership. In Raft, a leader that loses contact with the majority does not know it has lost contact. It continues to believe it is the leader until its election timer expires. Any read served on that belief alone is potentially stale.
Workload: lock, with network partitions.
The bug. The lock partition’s fencing counter rolled back 36 grants and replayed them with different owners:
… 113(n3) 114(n3) 115(n5) ← monotonic to 115
79(n5) 80(n5) 81(n4) ← restarts at 79
82(n5) 83(n3) 84(n5) … ← monotonic again
Each replayed token went to a different owner than the first time. This happened five seconds after a single node was isolated, on the majority side.
The root cause was in Kommander, not Kahuna. When a new leader was
promoted, its WAL drain read the log while enqueued writes still sat in
the write scheduler’s queue. The drain missed those writes. A fencing
token is minted from entry.FencingToken + 1, where the
entry comes from locally applied state. The newly promoted leader’s
state was incomplete, so it minted tokens from a stale base.
The first fix attempt (Kommander 1.0.9) made the problem worse. It
introduced gap-detection logic that misclassified the queued writes as
holes and orphaned everything above them on ordinary commits. A second
attempt produced :token-reused-by-other-owner, a worse
violation than monotonicity: two different owners held the same fencing
token.
The fix. Kommander 1.0.10 corrected the WAL drain to account for writes still in the scheduler’s queue.
Verified: Two independent sets of 8 runs each, all clean (16 of 16). At the 25% prior failure rate, 16 consecutive clean runs is a roughly 1% outcome by chance.
The lesson. A state machine that promotes to leader must finish applying all committed entries before it serves any request. If the promotion drain races with a write queue, the promoted leader serves from an incomplete projection of committed state.
Workload: append, with partitions and kills.
The append workload found two distinct anomalies in the transaction path.
Lost update (incompatible-order). Two transactions
read the same base list [17, 18, 19]. One appended 22 and
the other appended 10. Both committed. The result was two incompatible
lists: [17, 18, 19, 22] and [17, 18, 19, 10].
Elle ruled out every isolation model down to and including
read-committed.
The root cause was three stacked defects. The one that this
workload’s shape exposes: a read-modify-write’s base was never
validated. The intent’s BaseRevision was nominal and folded
only into the dedup digest. Once the in-memory write-intent lease
expired, a read-modify-write committed blind over a moved base in every
locking and validation mode.
The fix. Each read-then-written key’s pre-write observation is now frozen into its prepared intent as the validated base. A staged-base compare-and-set runs before anything durable is proposed.
Aborted read (G1a). A transaction whose
commit-tx-session returned Aborted had its
append read by a later committed transaction. The server told the client
its transaction was aborted, but the writes were visible to other
transactions.
The root cause went through two rounds. The first fix addressed
exception-handling paths that reported a definite abort without first
installing a durable Abort record. The fix made
FinalizeAdmission.Rejected return MustRetry,
and made the rollback and session reaper install a durable Abort through
a record CAS before claiming RolledBack.
The anomaly recurred. The second root cause: the one-phase fast
path’s pre-propose validation set a conflict Aborted in
context.Result when its write-skew probe found a concurrent
intent, then fell back to the standard flow. The standard flow’s
re-validation passed and committed the transaction, but the client
received the stale Aborted from the first attempt.
Verified: 22 runs at the load configuration that reproduces the anomaly (concurrency 10, rate 15), 21 informative, 6713 committed transactions, zero anomalies.
The lesson. “Fixed” needs a verification protocol. The first verification of the aborted-read fix used 11 clean runs at a load level that could not reproduce the bug. At 7%, eleven clean runs happen roughly 44% of the time. The second verification used the correct load level and accumulated enough runs to distinguish a 7% defect from zero.
Workload: snapshot, with no faults.
The bug. With two or more concurrent snapshot holds, every hold except the oldest rewound to the oldest hold’s revision. A snapshot that should have read a recent value instead read the value as of an earlier snapshot’s pinned point. This produced 64 violating reads in a single local run, and it required no fault injection at all.
The root cause: when multiple holds were active, the snapshot floor collapsed onto the oldest hold’s revision. All subsequent holds inherited that floor instead of their own pinned point.
The fix. Commit 65fcc70 corrected the
floor calculation to track each hold’s revision independently.
The lesson. Safety properties must hold under normal operation, not only under faults. A bug that requires no nemesis to trigger is more serious than one that requires a specific fault combination, because every production deployment exercises the no-fault case continuously.
Workload: register, with majorities-ring partitions.
The bug. Under a majorities-ring partition (where both sides hold a majority by sharing an overlapping node), a follower that grants a higher-term vote does not adopt the term in memory. The deposed leader’s appends pass the term fence on that follower and continue to commit. Acknowledged writes are lost when the new leader reuses the same log indexes.
A concrete trace from the failing run:
n5/p3 Sending vote to n1:8082 on Term=2
n1/p3 proclaimed leader Term=2 Votes=3
n2/p3 Proposed logs Logs=98 <- Term=1, still committing
n5/p3 Received logs from leader n2 with Term=1 <- ACCEPTED
n2/p3 Committed proposal Logs=98 <- client gets :ok
n1/p3 Proposed logs Logs=98 <- index 98 reused, different entry
Node n5 had already voted for n1 in Term 2, but its in-memory
currentTerm stayed at 1. The append fence
(currentTerm > leaderTerm) evaluated to
1 > 1, which is false, so Term-1 appends from the
deposed leader n2 sailed through. The root cause was a conditional term
adoption gated on nodeState != Follower. Raft requires a
node to adopt term T when it grants a vote in term T, regardless of its
state.
The fix. Kommander now assigns
currentTerm = voteTerm unconditionally when granting a
higher-term vote, outside the step-down guard. A second fix makes a
leader step down and persist the newer term when an append
acknowledgment returns a higher term.
The lesson. Persisted state and in-memory state can diverge. The WAL recorded term 2 (from the vote persistence), but the running node still operated at term 1. A restart would have fenced correctly; the live node did not. Any safety check that reads only hard state would miss this window.
Workload: append, with range splits (no other faults).
The bug. A range split’s data copy captured base rows but missed committed-but-unsettled prepared intents. Reads served through the intent overlay on the original partition returned the correct value. After the split, reads on the new partition’s leader returned the pre-commit base.
The fix. A settle-before-cutover barrier now resolves all decided intents before the split’s copy step. An export-truncation fix and straggler re-routing completed the repair.
Workload: lock, with placement moves at replication factor 3.
The bug. Fencing tokens went backwards and were reused by different owners when the placement nemesis moved replicas during live lock traffic. The prior fencing fixes (verified at 16 of 16) were never exercised against replica movement because the placement controller moved nothing until the rebalancer was fixed.
The fix. Corrections to the WAL drain and state transfer paths ensured that a promoted leader on a moved replica starts from a complete projection of committed lock state.
Workload: append, with key-range routing.
The bug. Under key-range routing, all keys in a key space collapse onto a single partition (the whole-space descriptor), so multi-key transactions become single-partition. A G2-item write skew appeared: two transactions each read the other’s key as empty and committed conflicting appends. The anomaly preceded any range split by 93 seconds, so the split was excluded as a cause.
The fix. The root cause was traced to unsettled intents during a snapshot install triggered by a decommission drain. Dropping node-local cached entries on snapshot install, combined with the settle-before-cutover barrier, closed the anomaly.
All findings described in this chapter have been fixed and verified by the passing Jepsen CI suite.
A Jepsen run produces a directory of artifacts: the full operation history, the checker’s verdict, timeline plots, and (for Elle) the cycle that constitutes the violation. Reading these results correctly requires attention to several subtleties.
A property that fails at a 7% rate produces 11 consecutive clean runs roughly 44% of the time. The kahuna-jepsen suite was burned by this twice: once when 6 clean runs closed a write-skew finding that recurred, and once when 11 clean runs at a low load level closed an aborted-read finding that reproduced at a higher load level. The lesson: match the verification run count and configuration to the reproduction rate and conditions.
A run that commits zero transactions is not a clean result. It is an
uninformative result. The checker reports :valid? true
because there is nothing to violate, but the run provided no evidence.
Roughly 1 in 10 runs in the kahuna-jepsen suite committed zero
transactions, typically because of repeated kills that prevented cluster
initialization. Always check commit counts before counting a run as
evidence.
The range and placement checkers refuse to call a run valid when
their preconditions are not met. A placement run in which zero replicas
moved reports :valid? :unknown, :cause :vacuous rather than
:valid? true. This is intentional. Without the gate, ten
jobs with a broken rebalancer would all report green, and “placement
validated” would be the conclusion.
The aborted-read anomaly reproduced at concurrency 10 and rate 15 but never at concurrency 5 and rate 5. The original verification used the low-load configuration, and 20 clean runs at that configuration proved nothing about the high-load case. When a defect depends on timing, the verification must use the timing conditions that reproduce it.
In the Jepsen history, each operation records a process number. To
determine which node served the operation, apply these formulas:
thread = process mod concurrency,
node = thread mod 5 + 1. This mapping is necessary to
correlate client-side observations with server-side logs when diagnosing
a violation.
Embedded cluster tests run in a single process with in-memory communication. They verify that the code does the correct thing when messages arrive in order, when no messages are lost, and when no processes crash. These are necessary tests, but they exercise none of the failure modes that define distributed system correctness.
A network partition creates two groups of nodes that can each communicate internally but not with each other. If both groups contain a majority (a majorities-ring partition), both can elect leaders. If both leaders serve writes, committed data is lost when the partition heals. No in-memory communication layer simulates this.
A process crash at the wrong moment can lose data that the leader reported as committed. If the write-ahead log has not been flushed, or if a checkpoint has advanced past unflushed state, a restarted node serves from a stale projection. No in-memory storage layer simulates this.
Clock skew can cause a node to believe its lease is still valid when it has already expired from the perspective of every other node. No shared-process clock simulates this.
Jepsen fills this gap by running real processes on separate machines (or containers), injecting real faults through real system calls (iptables, SIGKILL, SIGSTOP, clock manipulation), and checking the resulting history against a formal model. The bugs it found in Kahuna were real bugs that affected real safety properties. None of them would have been caught by the embedded cluster tests.
The following table lists the flags relevant to the Jepsen test suite. These are Jepsen harness flags, not Kahuna server flags.
| Flag | Default | Purpose |
|---|---|---|
--workload |
(required) | Which workload to run: register, lock, append, sequencer, or snapshot |
--faults |
partition | Comma-separated fault types: partition, kill, pause, clock, membership, placement, range, or none |
--nodes |
n1,n2,n3,n4,n5 | Comma-separated node names |
--time-limit |
60 | Test duration in seconds |
--concurrency |
5 | Number of concurrent client threads |
--rate |
10 | Target operations per second |
--nemesis-interval |
15 | Seconds between nemesis operations |
--replication-factor |
0 | Per-partition replication factor (0 means default single-replica) |
--key-range |
false | Enable key-order routing instead of hash routing |
--health-interval |
2.0 | Seconds between readiness health-check samples |
False verification. A verification run set that uses the wrong load level, the wrong fault combination, or too few runs can produce a false “fixed” verdict. The kahuna-jepsen suite was burned by this pattern multiple times. Always match verification conditions to the conditions that reproduce the bug. Always compute the probability that the observed clean run count could occur by chance at the known reproduction rate.
Uninformative washouts. Runs that commit zero
transactions produce a clean checker verdict but provide no evidence.
This happened roughly 1 in 10 runs. The cause was nodes that opened
their HTTP port before cluster initialization completed, returning
MustRetry for every request. Discard these runs from the
evidence count.
Checker bugs. A checker is itself code, and checker code can have bugs. The lock checker produced false mutual-exclusion violations because it overwrote the release timestamp on retry, stretching the computed hold window past the actual release. The fix preserved the earliest release attempt’s timestamp. A false-positive checker is worse than a false-negative one: it erodes trust in real findings.
Sampling artifacts. Range maps and health checks are sampled at discrete intervals. A 2-second health-check interval reported a 50/50 split between initialization time and consensus recovery time that was pure quantization error. At 0.5 seconds, initialization turned out to be two orders of magnitude smaller. Lower the sampling interval before quoting a number.
This appendix is a compact syntax reference for the Kahuna script language. Chapter 7 explains each construct in detail with examples. Use this appendix as a quick-lookup card.
All keywords are case-insensitive. SET,
set, and Set are the same command. String
literals use single quotes ('hello') or double quotes
("hello"). Identifiers that collide with reserved words can
be escaped with backticks (`my/key`). Placeholders use the
@ prefix (@param1).
| Type | Literal syntax | Runtime type |
|---|---|---|
| Integer | 42, -7, 0,
0xFF |
64-bit signed integer |
| Float | 3.14, -0.5 |
64-bit double-precision |
| String | 'hello', "hello" |
UTF-8 string |
| Boolean | true, false |
Boolean |
| Null | null |
Null |
| Array | (no literal; created by .. range or multi-row
commands) |
List of values |
LET x = 42
LET name = "alice"
LET val = GET mykey
LET binds a name to an expression result. Variables are
scoped to the script execution.
| Command | Syntax | Description |
|---|---|---|
SET |
SET key value [flags] |
Write a value |
GET |
GET key |
Read the current value |
GET (revision) |
GET key @ revision |
Read a specific revision |
GET (as-of) |
GET key AS OF timestamp |
Read the value as of an HLC timestamp |
EXISTS |
EXISTS key |
Check whether the key exists |
EXISTS (revision) |
EXISTS key @ revision |
Check existence at a specific revision |
EXISTS (as-of) |
EXISTS key AS OF timestamp |
Check existence as of a timestamp |
DELETE |
DELETE key |
Delete the key |
EXTEND |
EXTEND key milliseconds |
Extend the key’s TTL |
GET BY BUCKET |
GET BY BUCKET prefix |
Read all keys under a prefix (returns array) |
GET BY BUCKET (as-of) |
GET BY BUCKET prefix AS OF timestamp |
Read all keys under a prefix as of a timestamp |
SCAN BY PREFIX |
SCAN BY PREFIX prefix |
Scan keys by prefix (returns array) |
SCAN BY PREFIX (as-of) |
SCAN BY PREFIX prefix AS OF timestamp |
Scan by prefix as of a timestamp |
All persistent commands accept an optional LET
binding:
LET x = GET mykey
LET items = GET BY BUCKET 'services'
LET found = EXISTS mykey
Ephemeral commands operate on ephemeral (non-replicated) storage.
Each persistent command has an ephemeral counterpart with an
E prefix:
| Persistent | Ephemeral |
|---|---|
SET |
ESET |
GET |
EGET |
EXISTS |
EEXISTS |
DELETE |
EDELETE |
EXTEND |
EEXTEND |
GET BY BUCKET |
EGET BY BUCKET |
SCAN BY PREFIX |
ESCAN BY PREFIX |
Ephemeral commands support the same flags, revision reads, as-of
reads, and LET bindings as their persistent
counterparts.
Flags follow the value expression in a SET or
ESET command. Multiple flags can be combined.
| Flag | Syntax | Description |
|---|---|---|
NX |
SET key value NX |
Set only if the key does not exist |
XX |
SET key value XX |
Set only if the key already exists |
EX |
SET key value EX milliseconds |
Set with a TTL (time to live) in milliseconds |
CMP |
SET key value CMP expected |
Set only if the current value equals expected
(compare-and-swap) |
CMPREV |
SET key value CMPREV revision |
Set only if the current revision equals revision |
NOREV |
SET key value NOREV |
Do not increment the revision counter |
Flags combine freely:
SET mykey "hello" NX EX 30000
SET counter 10 CMP 9 EX 60000
SET config "v2" CMPREV 5
| Operator | Symbol | Example |
|---|---|---|
| Addition | + |
x + 1 |
| Subtraction | - |
x - 1 |
| Multiplication | * |
x * 2 |
| Division | / |
x / 2 |
| Operator | Symbols | Example |
|---|---|---|
| Equal | =, == |
x = 10, x == 10 |
| Not equal | <>, != |
x <> 10, x != 10 |
| Less than | < |
x < 10 |
| Greater than | > |
x > 10 |
| Less than or equal | <= |
x <= 10 |
| Greater than or equal | >= |
x >= 10 |
| Operator | Symbols | Example |
|---|---|---|
| And | && |
x > 0 && x < 100 |
| Or | \|\| |
x = 0 \|\| x = 1 |
| Not | !, NOT |
!found, NOT found |
| Operator | Symbol | Example | Description |
|---|---|---|---|
| Range | .. |
1..10 |
Create an integer array from start to end |
| Array index | [] |
items[0] |
Access an element by index |
| Not set | NOT SET |
IF x = NOT SET THEN |
True when a GET found no value |
| Not found | NOT FOUND |
IF x = NOT FOUND THEN |
True when a key does not exist |
IF condition THEN
statements
END
IF condition THEN
statements
ELSE
statements
END
FOR item IN collection DO
statements
END
The collection is typically a range (1..10), an array
returned by GET BY BUCKET, or an array returned by
SCAN BY PREFIX.
BEGIN
statements
COMMIT
END
BEGIN (locking=pessimistic, timeout=5000)
statements
COMMIT
END
A BEGIN block opens a transaction. COMMIT
commits the transaction. ROLLBACK aborts it.
END closes the block. If the script ends the block without
COMMIT or ROLLBACK, the transaction is
auto-committed.
Scripts without a BEGIN block run in auto-commit mode:
each statement is an independent operation.
Options are specified in parentheses after BEGIN:
BEGIN (option=value, option=value)
| Option | Values | Default | Description |
|---|---|---|---|
locking |
pessimistic, optimistic |
pessimistic |
Locking mode for the transaction |
autoCommit |
true, false, yes,
no |
false |
Auto-commit after the last statement |
asyncRelease |
true, false, yes,
no |
false |
Release locks asynchronously after commit |
timeout |
integer (milliseconds) | server default | Transaction execution timeout |
admissionWait |
integer (milliseconds) | server default | Maximum time to wait for an admission slot |
snapshot |
integer (Unix epoch ms) | (none) | Pin reads to a specific MVCC snapshot |
priority |
background, low, normal,
high, critical |
normal |
Admission priority |
| Statement | Syntax | Description |
|---|---|---|
RETURN |
RETURN expression or RETURN |
Return a value from the script |
SLEEP |
SLEEP milliseconds |
Pause execution (integer argument) |
THROW |
THROW expression |
Raise a script error |
| Function | Arguments | Returns | Description |
|---|---|---|---|
abs(x) |
1 numeric | numeric | Absolute value |
pow(base, exp) |
2 numeric | numeric | Exponentiation |
round(x) |
1 numeric | numeric | Round to nearest integer |
ceil(x) |
1 numeric | numeric | Round up |
floor(x) |
1 numeric | numeric | Round down |
min(a, b) |
2 numeric | numeric | Smaller of two values |
max(a, b) |
2 numeric | numeric | Larger of two values |
| Function | Aliases | Arguments | Returns | Description |
|---|---|---|---|---|
to_int(x) |
to_integer, to_long,
to_number |
1 | integer | Convert to 64-bit integer |
to_float(x) |
to_double |
1 | float | Convert to 64-bit float |
to_string(x) |
to_str |
1 | string | Convert to string |
to_bool(x) |
to_boolean |
1 | boolean | Convert to boolean |
to_json(x) |
(none) | 1 | string | Serialize to JSON string |
| Function | Aliases | Arguments | Returns | Description |
|---|---|---|---|---|
is_int(x) |
is_integer, is_long |
1 | boolean | True if integer |
is_float(x) |
is_double |
1 | boolean | True if float |
is_string(x) |
is_str |
1 | boolean | True if string |
is_bool(x) |
is_boolean |
1 | boolean | True if boolean |
is_null(x) |
(none) | 1 | boolean | True if null |
is_array(x) |
(none) | 1 | boolean | True if array |
| Function | Arguments | Returns | Description |
|---|---|---|---|
concat(a, b) |
2 strings | string | Concatenate two strings |
upper(x) |
1 string | string | Convert to uppercase |
lower(x) |
1 string | string | Convert to lowercase |
length(x) |
1 string | integer | String length in characters |
len(x) |
1 string | integer | Alias for length |
| Function | Arguments | Returns | Description |
|---|---|---|---|
revision(x) |
1 (a GET result) | integer | Revision number of the value |
rev(x) |
1 (a GET result) | integer | Alias for revision |
expires(x) |
1 (a GET result) | integer | Expiry timestamp of the value (0 if none) |
current_time() |
0 | integer | Current UTC time as Unix epoch milliseconds |
| Function | Arguments | Returns | Description |
|---|---|---|---|
count(x) |
1 array | integer | Number of elements in the array |
The following words are reserved and cannot be used as bare identifiers. Escape them with backticks if they appear in key names.
AND, AS, AT,
BEGIN, BUCKET, BY,
COMMIT, CMP, CMPREV,
DEL, DELETE, DO,
EDEL, EDELETE, EEXISTS,
EEXTEND, EGET, ELSE,
END, ESCAN, ESET,
EX, EXISTS, EXTEND,
FALSE, FOR, FOUND,
GET, IF, IN, LET,
NOREV, NOT, NULL,
NX, OF, OR, PREFIX,
RETURN, ROLLBACK, SCAN,
SET, SLEEP, THEN,
THROW, TRUE, XX
From lowest to highest:
.. (range)|| (or)&& (and)=, ==, <>,
!= (equality)<, >, <=,
>= (comparison)+, - (additive)*, / (multiplicative)!, NOT (unary not, right-associative)[] (array index)Parentheses override precedence: (x + 1) * 2.
Placeholders let the caller pass parameters into a script at
execution time. A placeholder is an @ followed by an
alphanumeric name:
SET @key @value NX EX @ttl
The caller binds values to placeholders through the
KeyValueParameter list in the API call. Placeholders can
appear anywhere a key name or expression is expected.
BEGIN (locking=pessimistic, timeout=5000)
LET balance = GET `accounts/checking`
IF balance = NOT SET THEN
THROW "Account not found"
END
LET amount = to_int(balance)
IF amount < 100 THEN
ROLLBACK
END
SET `accounts/checking` (amount - 100)
SET `accounts/savings` (to_int(GET `accounts/savings`) + 100)
COMMIT
RETURN amount - 100
END
This appendix lists every configuration parameter that the Kahuna server accepts. Each parameter is a command-line flag. The tables show the flag name, its default value, and a short description.
Parameters are grouped by subsystem. When a parameter controls a limit and the default is 0, the feature is disabled until an operator sets a positive value. When a flag description says “0 = no limit,” Kahuna applies no bound on that dimension.
| Flag | Default | Description |
|---|---|---|
--host |
* |
Host address to bind for incoming connections |
--http-ports |
(none) | Ports for incoming HTTP connections |
--https-ports |
(none) | Ports for incoming HTTPS connections |
--https-certificate |
(empty) | Path to the HTTPS certificate file |
--https-certificate-password |
(empty) | Password of the HTTPS certificate |
These parameters control where Kahuna stores its data and its write-ahead log (WAL).
| Flag | Default | Description |
|---|---|---|
--storage |
rocksdb |
Storage backend: rocksdb, sqlite, or
memory |
--storage-path |
(empty) | Filesystem path for the data store |
--storage-revision |
(empty) | Storage revision tag |
--wal-storage |
rocksdb |
WAL backend: rocksdb, sqlite, or
memory |
--wal-path |
(empty) | Filesystem path for the WAL store |
--wal-revision |
v1 |
WAL revision tag |
--wal-sync-writes |
(off) | Enable synchronous (durable) WAL writes |
--disable-wal-sync-writes |
(off) | Disable synchronous WAL writes for faster local or test runs |
These parameters apply only when the rocksdb storage
backend is selected.
| Flag | Default | Description |
|---|---|---|
--rocksdb-shared-memory |
disabled | Share one block cache and write-buffer manager between the KV/locks backend and the WAL |
--rocksdb-shared-memory-budget-mb |
320 |
Total shared block-cache budget in MiB (the memtable sub-budget is drawn from this pool) |
--rocksdb-shared-memtable-budget-mb |
128 |
Memtable sub-budget in MiB, cost-charged into the shared cache; must be less than or equal to the total budget |
--disable-rocksdb-direct-reads |
(off) | Use buffered reads backed by the OS page cache instead of direct I/O (direct I/O is on by default) |
--rocksdb-statistics |
disabled | Collect RocksDB statistics and dump them to the LOG file periodically; adds per-operation overhead |
| Flag | Default | Description |
|---|---|---|
--initial-cluster |
(none) | Seed list of node endpoints for static discovery |
--join-existing |
false |
Join a running cluster as a new node; the seed list serves as the join target |
--graceful-leave-on-shutdown |
false |
On planned shutdown, commit a RemoveMember so the roster shrinks immediately; do not enable on rolling restarts |
--initial-cluster-partitions |
3 |
Number of Raft partitions created at cluster bootstrap |
--raft-nodename |
(empty) | Human-readable node name |
--raft-nodeid |
0 |
Numeric node identifier |
--raft-host |
localhost |
Host address for Raft consensus and replication traffic |
--raft-port |
2070 |
Port for Raft consensus and replication traffic |
--raft-zone |
(none) | Locality hint (zone or rack); the placement planner spreads replicas across distinct zones when set |
These parameters set the number of actor workers that serve each primitive. Each worker is a Nixie actor with its own inbox.
| Flag | Default | Description |
|---|---|---|
--locks-workers |
128 |
Lock actors (ephemeral and consistent) |
--keyvalue-workers |
128 |
Key-value actors (ephemeral and consistent) |
--background-writer-workers |
1 |
Background persistence writers |
--sequencer-workers |
128 |
Sequence actors |
The IO scheduler runs dedicated thread pools for backend reads and writes, separate from the Raft WAL read pool.
| Flag | Default | Description |
|---|---|---|
--read-io-threads |
4 |
Kommander WAL read threads |
--write-io-threads |
16 |
Kommander WAL write threads |
--backend-read-io-threads |
8 |
Kahuna backend read threads (point gets, scans), separate from the WAL pool |
--backend-write-io-threads |
1 |
Kahuna background-writer threads; the writer serializes on a single queue, so values above 1 create idle threads |
--backend-read-queue-depth |
4096 |
Per-partition pending-queue depth for backend reads; new reads are rejected with backpressure when full |
The shared executor multiplexes all Raft partitions onto a bounded thread pool. Each wake cycle drains operations in priority order: control, replication, client, maintenance.
| Flag | Default | Description |
|---|---|---|
--raft-enable-shared-executor-pool |
true |
Share a bounded thread pool across all partitions; required for thousands of partitions (e.g. after range splits) |
--raft-executor-pool-size |
0 (auto) |
Worker threads in the pool; 0 auto-sizes to the processor count |
--raft-max-drain-quantum-control |
8 |
Control-plane operations drained per wake cycle |
--raft-max-drain-quantum-replication |
4 |
Replication operations drained per wake cycle |
--raft-max-drain-quantum-client |
2 |
Client operations drained per wake cycle |
--raft-max-drain-quantum-maintenance |
1 |
Maintenance operations drained per wake cycle |
These parameters tune Raft leader election, heartbeat, and quorum-check behavior. The relationship between the heartbeat interval and the election timeout determines how quickly the cluster detects a failed leader.
| Flag | Default | Description |
|---|---|---|
--raft-heartbeat-interval |
500 ms |
Leader heartbeat interval |
--raft-recent-heartbeat |
100 ms |
Window within which a heartbeat is recent |
--raft-voting-timeout |
1500 ms |
Vote-request wait timeout |
--raft-start-election-timeout |
2000 ms |
Minimum election timeout |
--raft-end-election-timeout |
4000 ms |
Maximum election timeout |
--raft-start-election-timeout-increment |
100 ms |
Minimum randomization increment added to the election timeout |
--raft-end-election-timeout-increment |
200 ms |
Maximum randomization increment added to the election timeout |
--raft-election-timeout-seed |
0 |
Seed for deterministic election timeouts; 0 = random, non-zero = deterministic (testing only) |
--raft-leadership-barrier-timeout |
10000 ms |
How long a new leader waits for its promotion barrier entry to commit before it reverts to follower |
--raft-leadership-confirmation-timeout |
2000 ms |
Maximum time a read-index confirmation waits for a quorum ack |
--raft-enable-check-quorum |
false |
A leader that loses a majority of same-term acks for the check-quorum window steps down |
--raft-check-quorum-interval-multiplier |
8 |
Heartbeat intervals without a majority ack before the leader steps down |
--raft-check-leader-interval |
250 ms |
Leader liveness check interval |
--raft-timer-initial-delay |
2500 ms |
Delay before Raft timers start after node boot |
--raft-update-nodes-interval |
5000 ms |
Interval between node-registry refresh rounds |
--raft-slow-state-machine-log |
50 ms |
Threshold for logging a slow state-machine operation |
--raft-slow-wal-machine-log |
25 ms |
Threshold for logging a slow WAL operation |
| Flag | Default | Description |
|---|---|---|
--raft-compact-every-operations |
10000 |
Committed operations between automatic WAL compactions |
--raft-compact-number-entries |
100 |
WAL entries removed per compaction batch |
--raft-max-entries-per-compaction |
5000 |
Maximum WAL entries processed per compaction run |
--raft-max-queued-client-proposals |
2048 |
Client proposals queued per partition before backpressure kicks in |
--raft-max-wal-queue-depth-per-partition |
4096 |
Per-partition WAL write queue depth limit |
--raft-max-global-wal-queue-depth |
0 |
Global WAL write queue depth across all partitions; 0 = no limit |
--raft-max-wal-batch-size |
256 |
Maximum WAL writes batched per storage flush |
--raft-max-wal-group-batch-partitions |
64 |
Maximum partitions coalesced into one WAL group-commit batch |
--raft-wal-group-commit-linger-ms |
0 |
Group-commit linger window in milliseconds; 0 = disabled |
--raft-wal-single-fsync-commit |
true |
Ack on propose-quorum-durable and demote the commit marker to a lazy write (single-fsync fast path) |
--raft-sqlite-wal-shard-count |
0 (auto) |
SQLite shard databases for WAL; 0 = auto-size to processor count |
| Flag | Default | Description |
|---|---|---|
--raft-grpc-scheme |
https:// |
URL scheme for gRPC peer channels |
--raft-grpc-channels-per-node |
4 |
Pooled gRPC channels per peer node (clamped to 1 to 64) |
--raft-grpc-enable-multiple-http2-connections |
false |
Allow each channel to open multiple HTTP/2 connections |
--raft-grpc-enable-snapshot-compression |
false |
Compress snapshot transfers over gRPC |
--raft-grpc-enable-append-logs-coalescing |
false |
Coalesce multiple AppendLogs calls into one gRPC frame per write cycle |
--raft-grpc-append-logs-max-coalesce-batch |
256 |
Maximum AppendLogs items per coalesced gRPC frame |
--raft-http-scheme |
https:// |
HTTP scheme for REST-based Raft communication |
--raft-http-auth-bearer-token |
(empty) | Bearer token for REST-based Raft communication |
--raft-http-timeout |
5 s |
Request timeout for REST-based Raft communication |
--raft-http-version |
2.0 |
HTTP version for REST-based Raft communication |
--raft-max-pre-auth-request-body-bytes |
32 MiB |
Ceiling on a Raft REST request body before authentication |
--raft-transport-security |
(empty) | Transport security and node authentication settings (JSON) |
--raft-allow-insecure-certificate-validation |
(off) | Skip TLS certificate validation for inter-node gRPC; use only in development |
| Flag | Default | Description |
|---|---|---|
--raft-snapshot-receive-session-ttl |
30000 ms |
Idle time before a snapshot-receive session is discarded |
--raft-snapshot-max-pending-sessions |
8 |
Maximum concurrent snapshot-receive sessions across all partitions |
--raft-snapshot-max-pending-bytes |
512 MiB |
Total buffered bytes across all in-progress snapshot-receive sessions |
--raft-allow-legacy-snapshot-senders |
false |
Accept chunks from senders that predate session-metadata fields; for mixed-version clusters |
Backfill is the mechanism that ships missed entries to lagging followers. When a follower falls too far behind, the leader sends a full snapshot instead.
| Flag | Default | Description |
|---|---|---|
--raft-backfill-enabled |
true |
Ship catch-up batches to lagging followers; set false only when the deployment owns its catch-up story |
--raft-backfill-threshold |
10 |
Entries a follower may trail before active backfill starts |
--raft-max-backfill-entries-per-round |
128 |
Maximum entries shipped per heartbeat interval |
--raft-follower-saturation-backoff |
1000 ms |
Pause after a follower reports its WAL queue is saturated |
Learner promotion controls how a new node (learner) becomes a voting member. Gossip disseminates cluster state. SWIM is the failure detector that identifies dead nodes.
| Flag | Default | Description |
|---|---|---|
--raft-learner-promotion-lag |
10 |
Maximum entries a learner may trail and still be eligible for promotion |
--raft-learner-promotion-stable-window |
3000 ms |
Duration a learner must stay within promotion lag before it becomes a voter |
--raft-gossip-interval |
5000 ms |
Interval between gossip anti-entropy rounds |
--raft-gossip-fanout |
2 |
Random peers contacted per gossip round; 0 disables gossip |
--raft-ping-timeout |
500 ms |
SWIM ping timeout |
--raft-indirect-ping-fanout |
2 |
Intermediary nodes for indirect SWIM probes |
--raft-suspicion-timeout |
5000 ms |
Duration a node stays Suspect before it is declared Dead |
--raft-dead-member-eviction-grace |
30000 ms |
Grace period before a Dead node is removed from the roster |
--raft-ping-interval |
1000 ms |
Interval between SWIM ping rounds; 0 disables the failure detector |
--raft-enable-auto-rejoin |
true |
A node that finds itself removed from the roster re-runs the join flow instead of staying NotMember |
Quiescence stops per-partition heartbeats on idle partitions, which eliminates O(N x M) heartbeat traffic across many partitions. The SWIM failure detector provides node-level liveness instead.
| Flag | Default | Description |
|---|---|---|
--raft-enable-quiescence |
true |
Quiesce idle partitions; requires SWIM
(--raft-ping-interval > 0 and less than
--raft-start-election-timeout) |
--raft-quiesce-after |
1500 ms |
Idle time before a partition’s leader quiesces it |
The leader balancer runs on the P0 (partition-zero) leader and redistributes partition leadership across nodes. It uses a two-tier strategy: first it balances the leader count, then it balances load.
| Flag | Default | Description |
|---|---|---|
--raft-enable-leader-balancer |
false |
Master switch; enable only after all nodes support the feature |
--raft-leader-balancer-report-interval |
5000 ms |
Interval at which each node emits a load report on the gossip path |
--raft-leader-balancer-interval |
30000 ms |
Interval at which the P0 leader runs a planning pass |
--raft-leader-balancer-report-ttl |
20000 ms |
Maximum age of a load report before it is excluded from planning; must be greater than the report interval |
--raft-count-deadband |
1 |
Minimum leader-count imbalance above the ideal before the balancer emits moves |
--raft-load-imbalance-threshold |
0.25 |
Fractional load skew (max minus min over max) that triggers load-tier swaps when counts are balanced |
--raft-min-leader-stability-ms |
5000 ms |
Duration a partition must stay on one leader before it may be moved |
--raft-move-cooldown |
60000 ms |
Duration a partition is excluded from further moves after a transfer suggestion |
--raft-max-moves-per-pass |
4 |
Maximum move suggestions per planning pass |
--raft-max-concurrent-transfers |
2 |
Maximum in-flight transfer suggestions tracked simultaneously |
--raft-leader-balancer-ops-weight |
1.0 |
Weight of the ops/sec term in the composite load score |
--raft-leader-balancer-queue-weight |
0.5 |
Weight of the queue-depth term in the composite load score |
--raft-suggestion-timeout |
15000 ms |
Time the P0 leader waits for a move to be confirmed before it declares the move dropped |
--raft-enable-load-reports |
false |
Gossip per-partition load reports even when nothing else consumes them |
The placement rebalancer runs on the P0 leader. It maintains the desired replication factor by adding or removing replicas, and it spreads replicas evenly across nodes and zones.
| Flag | Default | Description |
|---|---|---|
--raft-replication-factor |
0 |
Desired voter replicas per partition range; 0 = full replication (every voter hosts every range) |
--raft-enable-placement-rebalancer |
false |
Master switch for continual replica-placement rebalancing; initial placement at the configured replication factor is applied regardless |
--raft-placement-pass-interval |
5000 ms |
Interval between placement-controller passes |
--raft-max-replica-moves-per-pass |
4 |
Maximum new replica moves per pass, across all priorities |
--raft-max-concurrent-replica-transfers |
1 |
In-flight balance moves (cosmetic skew spreading) at any time |
--raft-max-concurrent-replica-repairs |
3 |
In-flight repair moves (re-replicating under-replicated ranges) at any time; budgeted separately from balance moves |
--raft-decommission-drain-timeout |
120000 ms |
Duration a graceful leave waits for replicas to be evacuated; on expiry the node is restored to voter |
--raft-replica-count-deadband |
1 |
Minimum per-node replica-count imbalance before balancing moves are emitted |
| Flag | Default | Description |
|---|---|---|
--revisions-to-cache |
4 |
Number of key revisions kept in the per-actor memory cache |
--cache-entry-ttl |
1800 s |
Maximum age of a cache entry before eviction (30 minutes) |
--cache-entries-to-remove |
100 |
Maximum entries removed per eviction sweep |
--script-cache-expiration |
600 s |
Compiled-script cache TTL (10 minutes) |
Internal cache parameters (set in KahunaConfiguration,
not exposed as CLI flags):
| Parameter | Default | Description |
|---|---|---|
MaxEntriesPerActor |
50,000 |
Maximum cache entries per key-value actor |
MaxBytesPerActor |
256 MiB |
Maximum cache bytes per key-value actor |
CollectBatchMax |
1,000 |
Maximum entries evicted per collection sweep |
CollectionInterval |
60 s |
Interval between collection sweeps |
ScriptCacheMaxEntries |
1,000 |
Maximum compiled scripts held in the cache |
MaxKeyValueActorInboxSize |
16,384 |
Per-actor inbox bound for ordinary messages; control messages are exempt |
The partition write aggregator coalesces direct key-value writes (SET, DELETE, EXTEND) into Raft proposals. This reduces the number of fsyncs.
| Flag | Default | Description |
|---|---|---|
--kv-write-linger-ms |
1 |
Delay from the oldest queued write before its partition batch is proposed; 0 = immediate |
--kv-write-max-batch-items |
512 |
Maximum log entries per aggregator Raft call |
--kv-write-max-batch-bytes |
4 MiB |
Target serialized bytes per aggregator Raft call |
--kv-write-max-queued-items |
8192 |
Maximum admitted direct writes per partition (including in-flight) |
--kv-write-max-queued-bytes |
32 MiB |
Maximum admitted serialized bytes per partition (including in-flight) |
--kv-write-max-queue-delay-ms |
1000 |
Maximum time a write waits before dispatch; expired writes are released as MustRetry |
--kv-write-aggregator-inbox-size |
16384 |
Ordinary-submission inbox bound per aggregator lane; control messages are exempt |
Internal write-batching parameters (set in
KahunaConfiguration, not exposed as CLI flags):
| Parameter | Default | Description |
|---|---|---|
KeyValueWriteTerminalReserveItemsPerPartition |
256 |
Extra item headroom per partition reserved for transaction settlement |
KeyValueWriteTerminalReserveBytesPerPartition |
4 MiB |
Extra byte headroom per partition reserved for transaction settlement |
KeyValueWriteMaxQueuedItemsGlobal |
131,072 |
Node-global item cap for ordinary writes across all partitions |
KeyValueWriteMaxQueuedBytesGlobal |
512 MiB |
Node-global byte cap for ordinary writes across all partitions |
KeyValueWriteTerminalReserveItemsGlobal |
8,192 |
Extra node-global item headroom reserved for transaction settlement |
KeyValueWriteTerminalReserveBytesGlobal |
64 MiB |
Extra node-global byte headroom reserved for transaction settlement |
KeyValueWriteMaxOperationBytes |
64 MiB |
Hard ceiling on a single write’s serialized bytes |
KeyValueWriteBatchExecutionTimeoutMs |
30,000 ms |
Timeout for a dispatched batch’s Raft round trip |
KeyValueWriteMaxQueueDelayMs |
1,000 ms |
Maximum residence before a write is released as MustRetry |
ReadContinuationTimeout |
30,000 ms |
Timeout for a backend read in flight; on expiry, waiters receive MustRetry |
Transaction admission control gates how many script transactions and interactive sessions may execute concurrently. Chapter 28 explains the five priority levels, reserved slots, and anti-starvation aging in detail.
| Flag | Default | Description |
|---|---|---|
--default-transaction-timeout |
5000 ms |
Default transaction lifetime timeout |
--default-admission-wait |
5000 ms |
Default time a caller queues for an admission slot; this is the wait to start, not the transaction lifetime |
--max-admission-wait |
30000 ms |
Hard upper bound on any admission wait |
--max-concurrent-transactions |
0 |
Script transactions that may execute concurrently; 0 = no limit (admission control disabled) |
--max-concurrent-sessions |
0 |
Interactive sessions that may be open concurrently; 0 = no limit |
--transaction-priority-reserved-slots |
0 |
Slots reserved for High and Critical priority only; 0 = no class distinction |
--transaction-priority-aging-threshold |
1000 ms |
Wait time for a queued transaction to gain one effective priority level; 0 = no aging |
--transaction-priority-max-queued |
4096 |
Maximum callers waiting per gate before further callers are refused; 0 = unbounded |
Internal transaction parameters (set in
KahunaConfiguration, not exposed as CLI flags):
| Parameter | Default | Description |
|---|---|---|
MaxTransactionTimeout |
300,000 ms |
Hard upper bound on any transaction session lifetime (5 minutes) |
Phase2CommitTimeout |
5,000 ms |
Timeout for a single two-phase-commit Raft wait |
TransactionOutcomeRetentionMax |
10,000 |
Finalized outcomes retained for best-effort idempotency |
TransactionOutcomeRetentionTtl |
5 min |
Age before a retained outcome is pruned |
DurableDecisionOutstandingMax |
100,000 |
Maximum outstanding durable coordinator decision records |
DurableRecordGcMaxPerPass |
4,096 |
Terminal records the GC sweep considers per pass |
CompletionReceiptRetentionTtl |
10 min |
Age after which an orphaned participant receipt is dropped |
DurableRecoveryMaxPartitionsPerPass |
64 |
Maximum partitions driven per prepared-intent recovery sweep |
DurableDeferredSettlement |
true |
Run post-decision resolution off the commit critical path |
DurablePreparedIntentMaxCount |
500,000 |
Maximum resident prepared intents across all partitions |
DurablePreparedIntentMaxBytes |
1 GiB |
Maximum resident prepared-intent value bytes |
DurableDecisionDeadlineFloorMs |
5,000 ms |
Lower bound on the decision-deadline margin |
DurableDecisionDeadlineCeilingMs |
60,000 ms |
Upper bound on the decision-deadline margin |
DurableDecisionDeadlineMultiplier |
4 |
Multiplier applied to finalize p99 when deriving the deadline margin |
| Flag | Default | Description |
|---|---|---|
--sequencer-block-size |
1000 |
Values reserved per compare-and-swap; one Raft commit serves this many values. Set to 1 for gap-free allocation |
--sequencer-idempotency-retention-max |
256 |
Maximum idempotency entries per sequence record; 0 = no cap |
--sequencer-idempotency-retention-ttl |
600 s |
Window within which a retried keyed reserve replays the same allocation; 0 = no age pruning |
--sequencer-max-sequences-per-actor |
10000 |
Maximum sequences resident per actor before LRU eviction; 0 = unbounded |
--sequencer-block-lease |
5 s |
Duration a reserved block is served from memory before revalidation against the durable record; 0 = never revalidate |
| Flag | Default | Description |
|---|---|---|
--checkpoint-interval |
30 s |
Period at which a dirty partition checkpoints, advancing its WAL retention floor |
--dirty-objects-writer-delay |
200 ms |
Flush interval of the dirty-object writer |
--persistent-revision-retention-count |
0 |
Maximum persisted key revisions to keep per key; 0 = keep forever |
--persistent-revision-retention-age |
0 s |
Maximum age of persisted revisions; 0 = no age limit |
--persistent-revision-cleanup-interval |
300 s |
Minimum interval between full revision cleanup sweeps |
--persistent-revision-cleanup-batch-size |
1000 |
Maximum revision records deleted per cleanup pass |
--persistent-revision-cleanup-on-write |
enabled | Run targeted cleanup after key-value writes |
--disable-persistent-revision-cleanup-on-write |
(off) | Disable targeted cleanup after writes |
Internal persistence parameter (set in
KahunaConfiguration, not exposed as a CLI flag):
| Parameter | Default | Description |
|---|---|---|
RevisionRetention |
16 |
In-memory revision slots retained per key |
| Flag | Default | Description |
|---|---|---|
--pitr-window |
3600 s |
PITR recovery window; WAL entries older than now minus this value may be compacted (1 hour) |
--base-snapshot-interval |
1800 s |
Interval between base checkpoints per shard; must not exceed the PITR window (30 minutes) |
--pitr-backup-dir |
(empty) | Root directory for backup artifacts and manifests; empty = backups disabled |
--pitr-backup-target |
local |
Storage target: local keeps backups on disk, other
values require a registered storage provider |
--pitr-backup-scratch-dir |
(empty) | Local staging directory for remote-target backups; size it for one full backup |
--pitr-backup-cluster-id |
(empty) | Cluster identity stamped into manifests; set the same value on every node |
--pitr-backup-mac-key-file |
(empty) | Path to the HMAC-SHA-256 key file for manifest authentication; keep it outside the backup directory |
--pitr-restore-root |
(empty) | Server-owned root that confines restore destinations; setting this enables remote restore |
--pitr-allow-unconfined-remote-restore |
false |
Allow remote restore without a configured restore root; insecure |
--pitr-bootstrap-from |
(none) | Backup ID (GUID) to restore from before the node joins the cluster;
requires --join-existing and
--pitr-backup-dir |
--pitr-target-time-ms |
0 |
HLC target time for PITR restore in milliseconds since Unix epoch; 0 = restore to the chain’s natural maximum |
--backup-retention-max-chains |
0 |
Keep at most this many recent backup chains; 0 = no limit |
--backup-retention-max-age |
0 s |
Delete chains whose newest backup exceeds this age; 0 = no limit |
--backup-retention-max-bytes |
0 |
Keep recent chains within this byte budget (the newest chain is always kept); 0 = no limit |
--backup-gc-interval |
3600 s |
Interval between periodic backup GC passes; a pass also runs at startup (1 hour) |
--backup-restore-throttle-mbps |
0 |
Restore copy throughput budget in MB/s; 0 = unlimited |
| Flag | Default | Description |
|---|---|---|
--range-split-threshold |
1000 |
Key count above which a range splits; 0 disables count-based auto-split |
--range-split-min-range-size |
10 |
Minimum keys each half must hold after a split |
--range-split-settle-window |
10 s |
Settle time after a split before the range may split again |
--range-collection-interval |
60 s |
Interval between split/merge sampling passes |
--range-merge-min-size |
10 |
Key count below which two adjacent ranges merge; 0 disables auto-merge |
Load-based splitting fires when a partition sustains high throughput and its WAL queue is saturated. All thresholds must be met simultaneously.
| Flag | Default | Description |
|---|---|---|
--range-split-load-threshold |
0 |
Log ops/sec above which a partition becomes a load-split candidate; 0 disables load-based auto-split |
--range-split-load-min-queue-depth |
8 |
Minimum WAL queue depth that must accompany the ops/sec rate |
--range-split-load-window |
15 s |
Duration the load predicate must hold before a split fires |
--range-split-load-poll-interval |
5 s |
Interval between load-signal polls |
Internal load-split parameters (set in
KahunaConfiguration, not exposed as CLI flags):
| Parameter | Default | Description |
|---|---|---|
RangeSplitLoadMinCommitWaitMs |
0 |
Optional secondary gate on commit-wait latency; 0 = disabled |
RangeSplitLoadImbalanceMax |
0.8 |
Fraction above which a range is refused as indivisible (all writes hit one key) |
RangeSplitIndivisibleCooldown |
5 min |
Suppression window after an indivisibility refusal |
The following parameters are set in KahunaConfiguration
and are not exposed as CLI flags. They exist for embedded deployments
that construct the configuration object directly in code.
| Parameter | Default | Description |
|---|---|---|
SingleProcessRaftGroup |
false |
Declares that all Raft voters live in one process; unlocks the one-phase durable-commit fast path for read-carrying transactions. Set automatically by the embedded standalone node. Do not set when a remote replica can join. |
BackupStorageProvider |
null |
Host-supplied factory for backup storage; set in code by the DI wiring, not from the command line |
RestoreRoot |
(empty) | Server-owned root for restore path confinement (the CLI equivalent
is --pitr-restore-root) |
AllowUnconfinedRemoteRestore |
false |
Escape hatch for remote restore without confinement (the CLI
equivalent is --pitr-allow-unconfined-remote-restore) |
This appendix lists every command available in
kahuna-cli, the Kahuna command-line tool. Commands fall
into two categories: flags passed on the command line for single-shot
execution, and commands typed in the interactive shell. Both categories
are documented here.
When no command flag is passed, kahuna-cli enters the
interactive shell. The shell provides syntax highlighting, multi-line
editing, and command history.
These options apply to all commands:
| Option | Short | Default | Description |
|---|---|---|---|
--connection-source |
-c |
localhost:8082,8084,8086 |
Comma-separated list of node endpoints |
--default-timeout |
-t |
10 |
Client-side timeout in seconds |
--format |
console |
Output format: console or json |
|
--insecure |
false |
Skip TLS certificate validation (for self-signed certificates) | |
--node |
(none) | Target a specific node endpoint for cluster commands | |
--force-rich |
false |
Force rich terminal rendering (or set
KAHUNA_FORCE_RICH=1) |
Write a value to a key.
CLI flag:
--set <key> --value <value> [--expires <ms>]
Interactive:
SET key value [NX | XX] [EX milliseconds] [CMP expected] [CMPREV revision]
The interactive form supports all SET flags described in Appendix A.
Read the current value of a key.
CLI flag: --get <key>
Interactive: GET key
Read all keys under a prefix (bucket scan).
CLI flag:
--get-by-prefix <prefix>
Interactive: GET BY BUCKET prefix
Scan keys by prefix and return their names.
CLI flag:
--scan-by-prefix <prefix>
Interactive: SCAN BY PREFIX prefix
Delete a key.
Interactive only: DELETE key or
DEL key
Extend a key’s TTL.
Interactive only:
EXTEND key milliseconds
Check whether a key exists.
Interactive only: EXISTS key
Every persistent key-value command has an ephemeral counterpart.
Ephemeral commands operate on non-replicated storage. In the interactive
shell, prefix the command with E:
| Persistent | Ephemeral |
|---|---|
SET |
ESET |
GET |
EGET |
DELETE / DEL |
EDELETE / EDEL |
EXTEND |
EEXTEND |
EXISTS |
EEXISTS |
GET BY BUCKET |
EGET BY BUCKET |
SCAN BY PREFIX |
ESCAN BY PREFIX |
Acquire a distributed lock.
CLI flag:
--lock <name> [--expires <ms>]
Interactive:
lock <name> [milliseconds]
The interactive form acquires the lock and holds it in the shell
session. The lock is released when the session ends or when
unlock is called.
Release a held lock.
CLI flag:
--unlock <name> --owner <owner-id>
Interactive: unlock <name>
In the interactive shell, the owner is tracked automatically from the
lock call.
Extend a held lock’s lease.
CLI flag:
--extend-lock <name> --owner <owner-id> [--expires <ms>]
Interactive:
extend-lock <name> [milliseconds]
Display information about a held lock.
Interactive only:
get-lock <name>
| Persistent | Ephemeral |
|---|---|
lock |
elock |
unlock |
eunlock |
extend-lock |
eextend-lock |
get-lock |
eget-lock |
Create a persistent sequence.
CLI flag:
--create-sequence <name> [--initial-value <n>] [--increment <n>] [--max-value <n>]
Interactive:
create-sequence <name> [initial-value] [increment] [max-value]
| Option | Default | Description |
|---|---|---|
--initial-value |
0 |
Starting value |
--increment |
1 |
Step size per allocation |
--max-value |
(none) | Optional upper bound |
Display the current state of a sequence.
CLI flag:
--get-sequence <name>
Interactive:
get-sequence <name>
Reserve the next value from a sequence.
CLI flag:
--next-sequence <name> [--idempotency-key <key>]
Interactive:
next-sequence <name> [idempotency-key]
Reserve a range of values from a sequence.
CLI flag:
--reserve-sequence <name> [--count <n>] [--idempotency-key <key>]
Interactive:
reserve-sequence <name> [count] [idempotency-key]
| Option | Default | Description |
|---|---|---|
--count |
1 |
Number of values to reserve |
Delete a sequence.
CLI flag:
--delete-sequence <name>
Interactive:
delete-sequence <name>
Load and execute a script file.
Interactive only:
run <path-to-file.4gl>
The script runs as a single transaction. The interactive shell also accepts inline script syntax (SET, GET, LET, IF, BEGIN, and so on) directly at the prompt.
Display the live cluster membership roster.
CLI flag: --cluster-members
Interactive: cluster members
Shows each member’s endpoint, node ID, role (Learner, Voter, or Leaving), and joined version.
Decommission a node from the cluster.
CLI flag:
--cluster-leave [--node <endpoint>]
Interactive:
cluster leave [endpoint]
When the connection lists several endpoints, the --node
option (or the endpoint argument in the interactive form) is required.
The CLI refuses to pick a node at random.
Display the per-partition replica placement table.
CLI flag:
--cluster-placement [--node <endpoint>]
Shows replicas, roles, effective replication factor, and which partitions are hosted locally.
Set a per-partition replication factor override.
CLI flag:
--set-replication-factor <rf> --partition <id> [--node <endpoint>]
The command is leader-only. It tries each connected endpoint until
the meta-partition leader accepts (or targets one node with
--node). A value of 0 clears the override.
Display the key-range map.
CLI flag:
--ranges [--key-space <space>] [--node <endpoint>]
Shows the ranges per key space, the partition serving each range, and
that node’s routing mode. Use --key-space to filter by a
specific key space.
Switch a key space to key-range routing and seed its whole-space descriptor.
CLI flag:
--register-key-range <key-space> [--node <endpoint>]
The command is sent to every connected endpoint, because the
routing-mode change is node-local. Use --node to target a
single node.
Remove a key space’s range descriptors.
CLI flag:
--unregister-key-range <key-space> [--node <endpoint>]
Sent to every connected endpoint, like
--register-key-range.
Split the range covering a key at that exact key.
CLI flag:
--split-range <key-space> --split-key <key> [--node <endpoint>]
The covering range [S,E) becomes [S,key)
and [key,E). The split key lands in the upper half. The
command is leader-only.
Run the merge pass across all key spaces.
CLI flag:
--merge-ranges [--node <endpoint>]
Folds adjacent ranges that have fallen below the configured minimum. The command is leader-only.
Take a full backup on the connected node.
CLI flag: --backup-full
Interactive: backup full
Take an incremental backup from a parent.
CLI flag:
--backup-incremental --parent-backup-id <guid>
Requires the parent backup ID (a UUID).
Take a coordinated full backup using the cluster-wide safe snapshot timestamp.
CLI flag: --backup-coordinated
Interactive: backup coordinated
List all backups in the node’s catalog.
CLI flag: --list-backups
Interactive: list backups
Resolve and validate the backup chain for a leaf backup.
CLI flag:
--backup-chain <guid>
Run backup garbage collection.
CLI flag:
--backup-gc [--backup-gc-dry-run]
Reclaims orphaned artifacts and enforces retention policies. Add
--backup-gc-dry-run to preview what would be reclaimed
without deleting anything.
Restore a backup chain to a directory.
CLI flag:
--restore <leaf-backup-guid> --target-dir <path> [--target-time-ms <ms>]
| Option | Default | Description |
|---|---|---|
--target-dir |
(required) | Destination directory for the restored data |
--target-time-ms |
0 |
Point-in-time target as Unix epoch milliseconds (0 = chain maximum) |
These commands are available only inside the interactive shell:
| Command | Description |
|---|---|
clear |
Clear the terminal screen |
exit or quit |
Exit the shell (releases all held locks and saves history) |
| Ctrl+C on empty prompt | Exit the shell (same as exit) |
The interactive shell also accepts the full Kahuna script language directly at the prompt. Any statement from Appendix A (LET, SET, GET, IF, BEGIN, FOR, and so on) can be typed or pasted. Multi-line input is supported.
Every Kahuna API call returns a typed status value that tells the caller what happened and what to do next. This appendix lists every status value for each subsystem. Values are grouped into three categories:
Numeric codes appear in parentheses where the enum assigns an explicit integer.
The KeyValueResponseType enum describes the outcome of
every persistent and ephemeral key-value operation: SET, GET, DELETE,
EXTEND, and EXISTS. Transaction-related operations (PREPARE, COMMIT,
ROLLBACK) also use this enum.
| Code | Name | Meaning |
|---|---|---|
| 0 | Set |
The value was written. |
| 1 | NotSet |
A conditional write (NX, XX, CMP, or CMPREV) did not match. The key was not changed. |
| 2 | Extended |
The key’s TTL was extended. |
| 3 | Get |
The value was read. |
| 4 | Deleted |
The key was deleted. |
| 5 | Locked |
The key was locked by the transaction. |
| 6 | Unlocked |
The key was unlocked. |
| 7 | Prepared |
The transaction was prepared (two-phase commit). |
| 8 | Committed |
The transaction was committed. |
| 9 | RolledBack |
The transaction was rolled back. |
| 10 | Exists |
The key exists (returned by EXISTS). |
| Code | Name | Meaning |
|---|---|---|
| 11 | WaitingForReplication |
The write was accepted locally but has not replicated to a quorum yet. The caller can wait or proceed. |
| 107 | SafeTimestamp |
The response carries a safe snapshot timestamp (used by coordinated backup). |
| Code | Name | Meaning |
|---|---|---|
| 99 | Errored |
An internal error occurred. The response carries a reason string. |
| 100 | InvalidInput |
The request is malformed (missing key, invalid TTL, or bad revision). |
| 101 | MustRetry |
A transient conflict occurred. The caller should retry the full operation. |
| 102 | Aborted |
The transaction was aborted by the system (deadlock, timeout, or admission refusal). |
| 103 | DoesNotExist |
The key does not exist. Returned by GET and DELETE when the key is absent. |
| 104 | AlreadyLocked |
The key is locked by another transaction. The caller must wait or abort. |
| 105 | PrefixLockUnsupportedOnRangedSpace |
Prefix locks are not supported on key spaces that use key-range routing. |
| 106 | RangeLocks |
The operation conflicts with a range lock held by another transaction. |
| 108 | AdmissionRefused |
The transaction admission controller rejected the request. The queue is full or the priority is too low. |
The LockResponseType enum describes the outcome of
distributed lock operations: LOCK, UNLOCK, EXTEND-LOCK, and
GET-LOCK.
| Code | Name | Meaning |
|---|---|---|
| 0 | Locked |
The lock was acquired. The response carries the owner ID and the fencing token. |
| 1 | Busy |
The lock is held by another owner. The caller did not acquire it. |
| 2 | Extended |
The lock’s lease was extended. |
| 3 | Unlocked |
The lock was released. |
| 4 | Got |
The lock state was read (returned by GET-LOCK). |
| Code | Name | Meaning |
|---|---|---|
| 10 | WaitingForReplication |
The lock operation was accepted locally but has not replicated to a quorum yet. |
| Code | Name | Meaning |
|---|---|---|
| 99 | Errored |
An internal error occurred. |
| 100 | InvalidInput |
The request is malformed (missing lock name or invalid TTL). |
| 101 | MustRetry |
A transient conflict occurred. Retry the operation. |
| 102 | LockDoesNotExist |
The named lock does not exist. Returned by UNLOCK and EXTEND-LOCK when no lock was ever acquired. |
| 103 | InvalidOwner |
The caller’s owner ID does not match the current holder. The lock belongs to another owner. |
| 104 | Aborted |
The operation was aborted by the system. |
The SequenceResponseType enum describes the outcome of
sequence operations: CREATE-SEQUENCE, GET-SEQUENCE, NEXT-SEQUENCE,
RESERVE-SEQUENCE, and DELETE-SEQUENCE.
| Code | Name | Meaning |
|---|---|---|
| 0 | Success |
The operation completed. For NEXT and RESERVE, the response carries the allocated value or range. |
| Code | Name | Meaning |
|---|---|---|
| 1 | NotFound |
The sequence does not exist. |
| 2 | AlreadyExists |
A sequence with this name exists already (returned by CREATE). |
| 3 | InvalidInput |
The request is malformed (invalid increment, negative count, or empty name). |
| 4 | MaxValueExceeded |
The next allocation would exceed the sequence’s configured maximum. |
| 5 | MustRetry |
A transient conflict occurred. Retry the operation. |
| 6 | Aborted |
The operation was aborted by the system. |
| 99 | Error |
An internal error occurred. |
The KahunaTransactionStatus enum describes the lifecycle
state of a transaction. The status appears in transaction responses and
in the internal transaction record.
| Code | Name | Meaning |
|---|---|---|
| 0 | Pending |
The transaction is open. It can accept reads and writes. |
| 1 | Finalizing |
The transaction is in the commit or rollback path. No new operations are accepted. |
| 2 | Committed |
The transaction committed. All writes are durable and visible. |
| 3 | Rolledback |
The transaction was rolled back. All writes were discarded. |
| 4 | Aborted |
The system aborted the transaction (deadlock, timeout, or leadership loss). |
The KahunaBackupOutcome enum describes the result of a
backup or restore operation. Chapter 27 covers the backup subsystem in
detail.
| Code | Name | Meaning |
|---|---|---|
| 0 | Ok |
The backup or restore completed. |
| Code | Name | Meaning |
|---|---|---|
| 1 | NotConfigured |
No backup directory is configured on this node. Set
--backup-directory. |
| 2 | ParentMissing |
The parent backup for an incremental does not exist in the catalog. |
| 3 | NeedsFull |
An incremental backup cannot proceed because no full backup exists yet. Take a full backup first. |
| 4 | CorruptChain |
The backup chain failed HMAC verification. One or more manifests were tampered with or corrupted. |
| 5 | CorruptArtifact |
A backup artifact (SST file or WAL segment) failed integrity verification. |
| 6 | TargetConflict |
The restore target directory is not empty. |
| 7 | TargetOutsideCoverage |
The point-in-time target falls outside the chain’s WAL coverage window. |
| 8 | Cancelled |
The operation was cancelled by the caller. |
| 9 | IoError |
A filesystem read or write failed. The reason string carries the OS error. |
| 10 | RetryableLeadershipLoss |
Leadership moved during the backup. Retry on the new leader. |
| 11 | ExactCheckpointUnavailable |
The requested checkpoint is not available in the WAL. |
| 12 | UnsupportedFormat |
The manifest format version is newer than this node can read. Upgrade the node. |
| 13 | TopologyChanged |
The cluster topology changed during a coordinated backup. Retry. |
| 14 | NotBackupCoordinator |
This node is not the backup coordinator. Send the request to the coordinator. |
| 15 | InsecureRoot |
The backup root directory has insecure permissions. |
| 16 | RestrictedCoverage |
The backup covers only a subset of partitions. The reason string names the missing ones. |
The GrpcLeaveClusterOutcome enum describes the result of
a node decommission request. Chapter 24 covers the membership
protocol.
| Code | Name | Meaning |
|---|---|---|
| 0 | Committed |
The node was removed from the cluster. Replica drain completed. |
| Code | Name | Meaning |
|---|---|---|
| 1 | NotAMember |
The target node is not a member of the cluster. |
| 2 | RefusedInsufficientVoters |
Removal would drop the voter count below the minimum needed for quorum. |
| 3 | NotInitialized |
The cluster has not completed initialization. |
| 4 | NoLeader |
No leader is available to process the request. |
| 5 | Timeout |
The operation timed out before the node could be removed. |
| 6 | RefusedDrainInProgress |
A drain is already in progress on this node. Wait for it to finish. |
| 7 | DrainTimedOut |
Replica drain started but did not complete within the allowed time. |
The split range response uses a string status field (not
a numeric enum). Each status value is either determinate or
indeterminate. A determinate status means the range map is in a known
state. An indeterminate status means the map may have changed, and the
caller must re-read GET /v1/ranges before it takes the next
action.
| Status | Meaning |
|---|---|
Succeeded |
The split completed. The range [S,E) became
[S,key) and [key,E). The response carries the
new partition ID and the new routing generation. |
| Status | Meaning |
|---|---|
NotLeader |
This node does not lead the partition that owns the range map. Retry
against the leader. The leaderHint field names the leader
when gossip knows it. |
NoRange |
No descriptor covers the key. The key space is unregistered or unseeded. |
InvalidSplitKey |
The key is outside the covering range, or equal to the range start. Either case would produce an empty half. |
BelowMinRangeSize |
One of the halves holds no keys. The split was refused by the minimum-size policy. |
PartitionCreationFailed |
The destination partition could not be created. No descriptor changed. A retry allocates a fresh partition ID. |
InvalidInput |
The request is malformed. |
KeyRangeDisabled |
The cluster has no data partition to split onto. |
| Status | Meaning |
|---|---|
TransferFailed |
Data transfer to the new partition failed mid-split. Re-read the map. |
QuiesceFailed |
The source partition could not quiesce writes. Re-read the map. |
CutoverFailed |
The routing cutover did not commit. Re-read the map. |
ConcurrentSplit |
Another split is in progress on the same range. Re-read the map. |
Indeterminate |
Leadership or transport was lost mid-split. Re-read the map. |
The merge response uses a string status field. A merge
pass scans every key space and folds adjacent ranges that fall below the
configured minimum size.
| Status | Determinate | Meaning |
|---|---|---|
Completed |
Yes | The pass ran to completion. The merges field tells how
many pairs it folded. A value of 0 means nothing was eligible. |
NotLeader |
Yes | This node does not lead the partition that owns the range map. Retry against the leader. |
Indeterminate |
No | The pass failed partway. Merges that already committed stay committed. Re-read the map. |
The register response uses a string status field.
Registration has two parts: a node-local routing-mode flip and a
replicated seed descriptor.
| Status | Meaning |
|---|---|
Seeded |
This call committed the whole-space descriptor. The key space is now key-range routed on this node. |
AlreadySeeded |
A descriptor already existed. This call still registered the routing mode on this node. |
Indeterminate |
The mode was flipped, but no descriptor is visible yet. The seed may have committed on the leader and not replicated within the wait. Re-read the map. |
InvalidInput |
Empty key space, or a /meta schema-log space. |
KeyRangeDisabled |
The cluster has no data partition to seed onto. Key-range routing cannot be enabled. |
The routingMode field in the response shows how this
node routes the space after the call: "KeyRange" when
registered, "Hash" when refused.
The remove response uses a string status field. Removal
clears the descriptors for a key space.
| Status | Meaning |
|---|---|
Removed |
The space carries no descriptors on this node. Removal is idempotent: this status appears whether this call removed them or they were already gone. |
Indeterminate |
The removal was accepted, but descriptors are still visible. The commit may not have applied yet. Re-read the map. |
QuiesceWindowOpen |
A split is mid-cutover on a range in this space. The quiesce window is short. Retry. |
InvalidInput |
Empty key space, or a /meta schema-log space. |
KeyRangeDisabled |
The cluster has no data partition. No space was ever key-range routed. |
The routingMode field shows the routing mode after the
call. The descriptorCount field shows how many descriptors
remain visible on this node. A successful removal shows 0.
Several patterns appear across all subsystems. The following table summarizes how to handle them.
| Pattern | Subsystems | Action |
|---|---|---|
MustRetry |
Key-value, locks, sequences | Retry the full operation from the start. The conflict was transient. |
Aborted |
Key-value, locks, sequences, transactions | The system cancelled the operation. Start a new transaction or operation. |
InvalidInput |
All | Fix the request. Do not retry with the same parameters. |
NotLeader |
Split, merge, cluster leave | Send the request to the leader. Use the leaderHint
field when it is present. |
Indeterminate |
Split, merge, register, remove | Re-read the current state with GET /v1/ranges before
the next action. Do not assume the operation failed. |
WaitingForReplication |
Key-value, locks | The write was accepted but not yet durable on a quorum. Wait or proceed, depending on the application’s consistency requirements. |
Errored / Error |
Key-value, locks, sequences | An internal error occurred. The response carries a reason string. Log it and contact the operator. |
This appendix compares Kahuna with four systems that operators often evaluate for similar workloads: etcd, ZooKeeper, Consul, and Redis. The comparison is factual, not promotional. Each system was built for a different primary use case, and the right choice depends on the workload.
| Feature | Kahuna | etcd | ZooKeeper | Consul | Redis |
|---|---|---|---|---|---|
| Key-value store | Yes | Yes | Yes (znodes) | Yes | Yes |
| Distributed locks | Yes (with fencing tokens) | Yes (via leases) | Yes (ephemeral znodes) | Yes (sessions) | Yes (Redlock) |
| Distributed sequencer | Yes (block-based) | No | No | No | No (manual with INCR) |
| Multi-key transactions | Yes (2PC, ACID) | Yes (mini-transactions) | Yes (multi-op) | Yes (single-key CAS) | Yes (MULTI/Lua) |
| Script language | Yes (Kahuna Script) | No | No | No | Yes (Lua) |
| MVCC / revision history | Yes (per-key version chain) | Yes (global revision) | Yes (per-znode version) | No | No |
| Snapshot reads (as-of) | Yes (HLC-based) | Yes (revision-based) | No | No | No |
| Range scans | Yes (key-range routing) | Yes (native) | Yes (children) | Yes (prefix) | Yes (SCAN) |
| Automatic sharding | Yes (hash + key-range) | No (single Raft group) | No (single leader) | No (single leader per DC) | Yes (Redis Cluster) |
| Watch / notifications | No | Yes (watch) | Yes (watcher) | Yes (blocking queries) | Yes (Pub/Sub, keyspace) |
| TTL / ephemeral data | Yes | Yes (leases) | Yes (ephemeral znodes) | Yes (sessions) | Yes (EXPIRE) |
| Backup / PITR | Yes (full, incremental, coordinated) | Yes (snapshot) | Yes (snapshot) | Yes (snapshot) | Yes (RDB/AOF) |
Kahuna uses per-partition Raft groups. Each partition is an independent Raft group with its own leader, log, and election cycle. The system can run many partitions across many nodes. Leadership is distributed: different partitions can have leaders on different nodes. This architecture scales write throughput horizontally because writes to different partitions do not contend for the same Raft log.
etcd uses a single Raft group for the entire data store. All writes go through one leader. The leader serializes all proposals into one log. This design is simple and provides strong consistency, but write throughput is bounded by the single leader’s capacity. etcd is designed for small metadata workloads (configuration, service discovery), not high-throughput data storage.
ZooKeeper uses a single-leader protocol (ZAB, a
Raft-like atomic broadcast). All writes go through the leader. Reads can
be served by followers, but follower reads may be stale unless the
client issues a sync call first. Like etcd, ZooKeeper is
designed for small coordination workloads.
Consul uses a single Raft group per datacenter. Cross-datacenter replication uses a gossip-based WAN federation, not Raft. Writes within a datacenter go through the local leader. Consul is primarily a service mesh and service discovery tool; its key-value store is a secondary feature.
Redis does not use consensus for its primary
replication. Redis uses asynchronous leader-follower replication. In
this mode, a write acknowledged by the leader may be lost if the leader
crashes before the follower receives it. Redis Sentinel provides
automated failover but does not guarantee that the promoted follower has
all writes. Redis Cluster adds hash-based sharding across multiple
leaders, but each shard still uses asynchronous replication by default.
The WAIT command can force synchronous replication to a
specified number of replicas, but this is opt-in per write.
Kahuna stores arbitrary byte values keyed by
strings. Keys are organized into key spaces (the portion before the last
/ separator). Each key maintains a version chain with
revision numbers and HLC timestamps. Deleted keys leave tombstones that
are eventually pruned.
etcd stores arbitrary byte values keyed by byte strings. etcd maintains a global revision counter that increments on every write. Watches observe changes by revision. The entire key space is one flat namespace with byte-order range queries.
ZooKeeper stores data in a hierarchical namespace of znodes (similar to a filesystem). Each znode can hold up to 1 MiB of data. Znodes have versions, ACLs, and optional ephemeral or sequential properties. The hierarchical model encourages a tree-structured naming convention.
Consul stores values keyed by strings in a flat key
space with / as a conventional separator. Values are
limited to 512 KiB. Consul does not maintain revision history. It
provides blocking queries (long-poll) for change notification.
Redis stores values in a flat key space with multiple data structure types: strings, hashes, lists, sets, sorted sets, streams, and more. Redis does not maintain revision history. Keys have optional TTL but no version chain.
Kahuna provides linearizable reads and writes through Raft read-index confirmation. Transactions provide snapshot isolation with serializable conflict detection. The system uses HLC timestamps for MVCC ordering.
etcd provides linearizable reads and writes. Mini-transactions (If/Then/Else) provide atomic conditional updates on multiple keys, but they execute in a single Raft round and cannot span multiple round-trips.
ZooKeeper provides linearizable writes. Reads from
the leader are linearizable. Reads from followers are sequentially
consistent (they may lag behind the leader). A sync call
forces a follower to catch up before the next read.
Redis provides eventual consistency in its default asynchronous replication mode. A write acknowledged by the leader is not guaranteed to survive a failover. Redis does not provide linearizable reads across replicas. Redis transactions (MULTI/EXEC) are serialized on a single node but do not span multiple nodes in Redis Cluster.
Consul provides linearizable reads when the
consistent query mode is used. The default mode
(stale) allows follower reads that may lag. Consul’s
key-value operations are single-key; there is no multi-key transaction
primitive.
Kahuna provides first-class distributed locks with fencing tokens. Each lock acquisition returns a monotonically increasing fencing token. The fencing token allows downstream services to reject stale operations from a lock holder whose lease expired. Locks support configurable TTL, automatic expiry, and explicit unlock.
etcd provides distributed locks through its lease mechanism. A client creates a lease with a TTL and associates a key with that lease. The key is deleted when the lease expires or is revoked. etcd does not provide built-in fencing tokens; the application must implement fencing using the key’s revision or create-revision.
ZooKeeper provides distributed locks through ephemeral sequential znodes. A client creates an ephemeral sequential znode under a lock path and watches the znode with the next-lower sequence number. The lock is released when the ephemeral znode is deleted (either explicitly or when the session expires). ZooKeeper does not provide fencing tokens.
Consul provides distributed locks through sessions. A client creates a session with a TTL and acquires a lock by writing a key with the session attached. The lock is released when the session is invalidated or expires. Consul does not provide fencing tokens.
Redis provides distributed locks through the Redlock algorithm. Redlock acquires locks on a majority of independent Redis instances and uses clock-based TTL for expiry. The Redlock algorithm has been the subject of academic debate about its safety properties under clock skew and network partitions. Redis does not provide fencing tokens as part of the lock primitive.
Kahuna provides two transaction models. Script transactions execute a sequence of operations atomically on the server. Interactive transactions allow the client to read, compute, and write across multiple round-trips, with a final two-phase commit. Both models support pessimistic and optimistic locking. Transactions can span multiple partitions. Conflict detection uses read-set validation and write-intent comparison.
etcd provides mini-transactions (Txn) that evaluate a set of conditions and execute one of two operation sets atomically. Mini-transactions execute in a single Raft round-trip. They cannot span multiple round-trips or perform client-side computation between reads and writes.
ZooKeeper provides multi-op transactions that execute a batch of operations atomically. Like etcd, these execute in a single round-trip. ZooKeeper does not support interactive transactions.
Consul does not provide multi-key transactions. Its key-value operations are single-key with CAS (check-and-set) semantics.
Redis provides MULTI/EXEC transactions that queue commands and execute them atomically on a single node. Redis transactions do not support conditional logic between commands (the WATCH command provides optimistic locking but not read-modify-write in a single transaction). In Redis Cluster, transactions are limited to keys that hash to the same slot.
Kahuna supports automatic sharding through two modes: hash-based routing (the default, which distributes keys evenly across a fixed partition pool) and key-range routing (which assigns contiguous key intervals to partitions and supports automatic splitting and merging). Write throughput scales with the number of partitions and nodes.
etcd does not shard data. All data lives in a single Raft group. Scalability is vertical: a larger machine handles more data and more throughput, up to the limits of a single Raft leader.
ZooKeeper does not shard data. All data lives on every node. Reads scale with the number of followers (when using stale reads), but writes do not scale beyond the single leader.
Consul does not shard its key-value store within a datacenter. Cross-datacenter federation provides geographic distribution but not throughput scaling.
Redis Cluster shards data across multiple leaders using hash slots (16,384 slots). Each leader handles a subset of the key space. Resharding is manual or semi-automatic. Redis Cluster does not support multi-key transactions across different hash slots.
| Use Case | Best Fit |
|---|---|
| Small configuration store (< 1 GiB) | etcd, ZooKeeper, Consul |
| Service discovery and health checking | Consul |
| Distributed coordination with fencing | Kahuna |
| High-throughput key-value with transactions | Kahuna, Redis |
| Caching with rich data structures | Redis |
| Leader election for microservices | Kahuna, etcd, ZooKeeper |
| Distributed sequence generation | Kahuna |
| MVCC reads at a past point in time | Kahuna, etcd |
| Multi-key ACID transactions | Kahuna |
| Watch-based change notification | etcd, ZooKeeper |
| Service mesh integration | Consul |
Kahuna occupies a different point in the design space than etcd, ZooKeeper, and Consul. Those three systems are optimized for small metadata workloads with a single Raft group. They provide strong consistency with simple operations. Kahuna is built for workloads that need multiple primitives (key-value, locks, sequences, transactions) under one system, with automatic sharding for throughput scaling.
Redis occupies yet another point. It provides the richest data
structure model and the highest single-node throughput, but its default
replication model trades consistency for performance. Applications that
need strong consistency with Redis must use the WAIT
command and accept the latency cost, or accept the risk of data loss on
failover.
The choice depends on the workload’s requirements: data size, throughput, consistency guarantees, transaction scope, and operational complexity. No single system is the best fit for every use case.
Actor. A concurrent processing unit with a private mailbox. Each actor processes one message at a time. Kahuna uses the Nixie actor framework. Key-value, lock, and sequencer partitions are each served by an actor.
Anchor partition. The partition that holds the canonical transaction record for a distributed transaction. The anchor is determined by hashing the transaction ID. All participants look up the anchor to learn whether a transaction committed or aborted.
Background writer. The
BackgroundWriterActor that batches committed Raft entries
and flushes them to the storage backend asynchronously. The background
writer decouples the Raft commit path from disk latency.
Backup chain. A sequence of backups that starts with a full backup and continues with zero or more incremental backups. Each incremental links to its parent by backup ID. Restoring from any point in the chain requires every ancestor back to the root full backup.
CAS (compare-and-swap). An atomic operation that
writes a new value only if the current value matches an expected value.
Kahuna supports CAS through the CMP flag on SET and through
the CMPREV flag that compares the revision number.
Commit. The act of making a write durable and visible. In Raft, a log entry is committed when a majority of nodes have appended it to their logs. In a transaction, a commit is the decision to apply all writes.
Completion receipt. A record that proves a transaction’s result has been applied to a specific key on a specific partition. Completion receipts prevent duplicate application during recovery.
Consensus. Agreement among a group of nodes on a single value or sequence of values, despite failures. Kahuna uses the Raft consensus algorithm through the Kommander library.
Coordinated omission. A measurement error in
benchmarks where slow responses reduce the request rate, which hides
tail latency. Open-loop benchmarks (such as
kahuna-bench --rate N) avoid this error by measuring
latency from the intended start time, not the actual start time.
Cutover. The atomic step in a range split or merge that swaps the old range descriptors for the new ones in a single Raft-committed mutation. After cutover, routing uses the new descriptors.
Deadband. A tolerance band that prevents oscillation in a balancing system. The leader balancer’s count deadband prevents leadership from bouncing between nodes when their leader counts differ by a small amount.
Deferred settlement. The practice of separating the transaction decision (commit or abort) from the resolution of individual write intents. After the decision is committed, intents are settled asynchronously. This reduces the latency of the commit path.
Descriptor. See Range descriptor.
Durability. The guarantee that a committed write survives node restarts and crashes. Kahuna achieves durability through Raft replication (the write exists on a majority of nodes) and persistence to the storage backend.
Durability floor. The highest WAL index whose state has been flushed to the storage backend for a given partition. Kommander uses this floor to decide when WAL entries can be compacted.
Ephemeral. Data that is not replicated through Raft and does not survive node restarts. Ephemeral key-value entries and ephemeral locks are stored only in the local node’s memory.
Fencing token. A monotonically increasing integer returned with each lock acquisition. Downstream services use the fencing token to reject operations from a lock holder whose lease expired. If a stale holder sends a request with token 5 but the current holder has token 6, the downstream service rejects the stale request.
Flush. The act of writing buffered data from memory to the storage backend. The background writer accumulates entries and flushes them in batches.
Generation. A version number on a range descriptor. The generation increments on every split, merge, or modification to the range. Clients carry the generation with each request. A stale generation causes a routing error that forces the client to refresh its range map.
Generation fence. The mechanism that rejects requests routed to a partition with a stale generation number. The fence prevents writes from landing on a range that no longer owns the key.
HLC (hybrid logical clock). A clock that combines a physical timestamp (wall-clock time) with a logical counter. HLCs provide a total order across events on different nodes without requiring perfectly synchronized clocks. Kahuna encodes HLCs as a 64-bit value: 44 bits for the physical timestamp (milliseconds), 16 bits for the logical counter, and 4 bits for flags.
Idempotent. An operation that produces the same result whether it is applied once or multiple times. Kahuna’s sequence reservations are idempotent: retrying a reservation with the same owner returns the same value.
Intent. See Write intent.
Key space. The portion of a key before the last
/ separator. Keys in the same key space share routing
decisions. A key space can be registered for key-range routing or left
on the default hash-based routing.
Kommander. The Raft consensus library that Kahuna uses. Kommander manages leader election, log replication, and state machine application for each partition.
Leader. The Raft node that handles all writes for a partition. The leader replicates log entries to followers and responds to clients after a majority acknowledges each entry.
Learner. A Raft node that receives replication traffic but does not count toward quorum. New nodes join the cluster as learners and are promoted to voters after they catch up.
Lease. A time-bounded grant. Distributed locks use leases: the lock is held for a specified duration and expires automatically if the holder does not renew it.
Linearizability. A consistency guarantee that every operation appears to take effect at a single instant between its invocation and its completion. The result is equivalent to a single serial execution. Kahuna provides linearizable reads and writes through Raft read-index confirmation.
Log entry. A single record in a Raft partition’s write-ahead log. Each entry has an index, a term, and a payload. The payload can be a key-value write, a lock operation, a transaction record, a range map change, or another replicated operation.
Meta partition. Partition zero. The meta partition stores the range map (range descriptors), the snapshot floor registry, and the cluster membership roster. It is a Raft group like any other partition, but it holds system metadata rather than user data.
MVCC (multi-version concurrency control). A concurrency control method that keeps multiple versions of each key-value entry. Readers see a consistent snapshot without blocking writers. Kahuna’s MVCC uses HLC timestamps to order versions.
Nemesis. In Jepsen testing, a fault injector. A nemesis introduces failures such as network partitions, process kills, or clock skew during a test run.
Nixie. The actor framework that Kahuna uses. Nixie provides actor lifecycle management, mailbox processing, and consistent-hash routing.
One-phase commit. An optimization for transactions where all writes land on the same partition. The prepare, validate, and commit steps collapse into a single Raft round-trip. The transaction skips the full two-phase commit protocol.
Partition. A subset of the data managed by an independent Raft group. Each partition has its own leader, log, and set of replicas. Partitions are the unit of scaling: more partitions distribute load across more nodes.
PITR (point-in-time recovery). The ability to restore data to any specific moment within the backup retention window. PITR replays WAL segments from a full backup checkpoint up to the target HLC timestamp.
Placement. The assignment of partition replicas to nodes. The placement rebalancer distributes replicas to maintain the target replication factor, spread replicas across zones, and balance replica counts.
Prepared intent. See Write intent.
Presumed-abort. A recovery rule for distributed transactions. If no committed decision record exists for a transaction, the transaction is presumed aborted. This rule allows recovery to clean up orphaned intents without contacting the coordinator.
Proposal. A write request submitted to the Raft leader for replication. The leader appends the proposal to its log and replicates it to followers. The proposal is committed when a majority acknowledges it.
Quiesce. A temporary pause in routing to a range during a split or merge. The quiesce step prevents new writes from arriving while the cutover executes.
Quorum. The minimum number of nodes that must agree
for a Raft decision to commit. For a group of N nodes, the quorum is
floor(N/2) + 1. A three-node group has a quorum of 2. A
five-node group has a quorum of 3.
Raft. A consensus algorithm that elects a leader and replicates a log of commands to a group of nodes. Raft guarantees that all nodes apply the same commands in the same order, even if some nodes fail.
Range descriptor. A record that maps a contiguous interval of keys (defined by a start key and an end key) to a partition. Range descriptors live on the meta partition and are replicated through Raft.
Range map. The collection of all range descriptors for all key spaces. The range map is the source of truth for key-range routing.
Read-index. A Raft mechanism for linearizable reads without writing to the log. The leader confirms that it still holds leadership by exchanging heartbeats with a majority, then serves the read at the committed index. Kahuna coalesces multiple concurrent read-index requests into a single heartbeat round.
Replication factor. The number of voter replicas for each partition. A replication factor of 3 means three nodes hold copies of each partition. A replication factor of 0 means full replication: every node holds every partition.
Revision. A version number on a key-value entry. The
revision increments on every write to that key. Clients use revisions
for conditional writes (CMPREV) and for reading historical
values (GET key @ revision).
Serializability. An isolation level where concurrent transactions produce the same result as some serial execution. Kahuna’s interactive transactions provide snapshot isolation with serializable conflict detection.
Snapshot floor. The minimum HLC timestamp below which MVCC revision pruning is blocked. Active snapshot holds pin the floor. The snapshot floor prevents the pruning system from deleting revisions that an active snapshot still needs.
Snapshot isolation. An isolation level where each transaction reads from a consistent snapshot taken at the transaction’s start time. Writers do not block readers. Write-write conflicts are detected at commit time.
Split. The act of dividing one range into two. A split creates a new partition for the right half of the key range and atomically updates the range descriptors.
State transfer. The process of copying all data for a partition from one node to another. State transfer is used when a follower’s WAL has been compacted past the entries it needs, so normal log replication cannot bring it up to date.
SWIM (Scalable Weakly-consistent Infection-style Membership). A failure detection protocol that uses direct and indirect probes to detect failed nodes. Kahuna uses SWIM to feed liveness information to the cluster membership system.
System partition. See Meta partition.
Term. A Raft concept that represents an election epoch. Each election increments the term. A node that discovers a higher term updates its own term and steps down if it was the leader. Terms provide a logical clock for leader identity.
Tombstone. A marker left when a key is deleted. The tombstone records the deletion revision and timestamp. Tombstones are eventually pruned by the revision cleanup system.
Transaction record. The canonical commit or abort decision for a distributed transaction. The transaction record lives on the anchor partition. All participants consult the record to learn the transaction’s outcome.
Two-phase commit (2PC). A protocol for atomic commitment across multiple partitions. Phase one (prepare) asks each participant to promise it can commit. Phase two (decide) records the commit or abort decision. Kahuna uses 2PC for interactive and script transactions that span multiple partitions.
Unflushed overlay. A read-through layer that bridges the gap between Raft-committed entries and entries flushed to the storage backend. The overlay stores committed entries in memory so that reads can see them immediately, even before the background writer flushes them.
Voter. A Raft node that counts toward quorum. Voters can vote in elections and can become leaders.
WAL (write-ahead log). A durable, append-only log that records every committed Raft entry. On restart, Kahuna replays the WAL to reconstruct in-memory state. The WAL is compacted after entries are flushed to the storage backend.
Write intent. A provisional write created during the prepare phase of a two-phase commit. The intent is visible to the transaction that created it but blocked for other transactions. When the transaction commits, the intent is materialized into a regular key-value entry. When the transaction aborts, the intent is discarded.
Zone. A locality label assigned to a node through
the --raft-zone flag. The placement planner spreads
replicas across distinct zones to tolerate zone-level failures (such as
a rack or availability zone going offline).