Client for .NET
Kahuna also provides a client tailored for .NET developers. This client simplifies the integration of distributed locking into your .NET applications by abstracting much of the underlying complexity. Documentation and samples for the client can be found in the docs/ folder or on our GitHub repository.
Client Installation
Kahuna Client for .NET is available as a NuGet package. You can install it via the .NET CLI:
dotnet add package Kahuna.Client
Or via the NuGet Package Manager:
Install-Package Kahuna.Client
Locks: Usage & Examples
Single attempt to acquire a lock
Below is a basic example to demonstrate how to use Kahuna Distributed Locks in a C# project:
using Kahuna.Client;
// Create a Kahuna client (it can be a global instance)
var client = new KahunaClient("https://localhost:8082");
// ...
public async Task UpdateBalance(KahunaClient client, string userId)
{
// try to lock on a resource using a keyName composed of a prefix and the user's id,
// if acquired then automatically release the lock after 5 seconds (if not extended),
// it will give up immediately if the lock is not available,
// if the lock is acquired it will prevent the same user from changing the same data concurrently
await using KahunaLock myLock = await client.GetOrCreateLock(
"balance-" + userId,
TimeSpan.FromSeconds(5)
);
if (myLock.IsAcquired)
{
Console.WriteLine("Lock acquired!");
// implement exclusive logic here
}
else
{
Console.WriteLine("Someone else has the lock!");
}
// myLock is automatically released after leaving the method
}
Multiple attempts to acquire a lock
The following example shows how to make multiple attempts to acquire a lock (lease) for 10 seconds, retrying every 150 ms.
Why Frequent Retries? Given that inventory updates are very short operations (typically milliseconds to a few seconds), each update releases the lock quickly. Still, with a massive volume of concurrent purchase attempts, the lock is rapidly cycled through many clients. As a result, individual servers might find that the lock is released often, but due to high contention, they need to retry multiple times until one of them succeeds.
using Kahuna.Client;
public async Task UpdateBalance(KahunaClient client, string userId)
{
// try to lock on a resource using a keyName composed of a prefix (balance) and the user's id,
// if acquired then automatically release the lock after 5 seconds or when leaving the method (if not extended),
// if not acquired retry to acquire the lock every 150 milliseconds for 10 seconds,
// it will give up after 10 seconds if the lock is not available,
// if the lock is acquired it will prevent the same user from changing the balance concurrently
await using KahunaLock myLock = await client.GetOrCreateLock(
"balance-" + userId,
expiry: TimeSpan.FromSeconds(5),
wait: TimeSpan.FromSeconds(10),
retry: TimeSpan.FromMilliseconds(150)
);
if (myLock.IsAcquired)
{
Console.WriteLine("Lock acquired!");
// implement exclusive logic here
}
else
{
Console.WriteLine("Someone else has the lock!");
}
// myLock is automatically released after leaving the method
}
Fencing Tokens
Whenever possible, it is also important to use the fencing tokens. Even if a client thinks it holds the lock post-lease expiration, fencing tokens prevent stale writes. In this example, the fencing token is used to perform optimistic locking:
using Kahuna.Client;
public async Task IncreaseBalance(KahunaClient client, string userId, long amount)
{
// try to lock on a resource holding the lease for 5 seconds
// and prevent stale clients from modifying data after losing their lock.
await using KahunaLock myLock = await client.GetOrCreateLock(
"balance-" + userId,
expiry: TimeSpan.FromSeconds(5)
);
if (myLock.IsAcquired)
{
Console.WriteLine("Lock acquired!");
BalanceAccount account = await db.GetBalance(userId);
if (account.FencingToken > myLock.FencingToken)
{
// Write rejected: Stale fencing token
Console.WriteLine("Someone else had the lock!");
return;
}
// Write successful: New balance saved with new fencing token
account.Balance += amount;
account.FencingToken = myLock.FencingToken;
await db.Save(account);
}
else
{
Console.WriteLine("Someone else has the lock!");
}
// myLock is automatically released after leaving the method
}
Periodically extend a lock
At times, it is useful to periodically extend the lock's expiration time while a client holds it, for example, in a leader election scenario. As long as the leader node is alive and healthy, it can extend the lock duration to signal that it can continue acting as the leader:
using Kahuna.Client;
public async Task TryChooseLeader(KahunaClient client, string groupId)
{
await using KahunaLock myLock = await client.GetOrCreateLock(
"group-leader-" + groupId,
expiry: TimeSpan.FromSeconds(10)
);
if (!myLock.IsAcquired)
{
Console.WriteLine("Lock not acquired!");
return;
}
long acquireFencingToken = myLock.FencingToken;
while (true)
{
(bool isExtended, long fencingToken) = await myLock.TryExtend(TimeSpan.FromSeconds(10));
if (!isExtended)
{
Console.WriteLine("Lock extension failed!");
break;
}
if (fencingToken != acquireFencingToken)
{
Console.WriteLine("Lock fencing token changed! Someone else took the lock");
break;
}
// wait 5 seconds to extend the lock
await Task.Delay(5000);
}
}
Retrieve information about a lock
You can also retrieve information about a lock, such as the current lock's owner and remaining time for the lock to expire:
using Kahuna.Client;
public async Task TryChooseLeader(KahunaClient client, string groupId)
{
await using KahunaLock myLock = await client.GetOrCreateLock(
"group-leader-" + groupId,
expiry: TimeSpan.FromSeconds(5)
);
if (!myLock.IsAcquired)
{
Console.WriteLine("Lock not acquired!");
var lockInfo = await myLock.GetInfo();
Console.WriteLine($"Lock owner: {lockInfo.Owner}");
Console.WriteLine($"Expires: {lockInfo.Expires}");
}
}
Configure a pool of endpoints
If you want to configure a pool of Kahuna endpoints belonging to the same cluster so that traffic is distributed in a round-robin manner:
using Kahuna.Client;
// Create a Kahuna client with a pool of endpoints
var client = new KahunaClient([
"https://localhost:8082",
"https://localhost:8084",
"https://localhost:8086"
]);
// ...
Using a pool of reachable endpoints instead of a load balancer can help reduce network latency, as the client can connect directly to healthy nodes without going through an additional proxy layer.
However, this comes at the cost of reduced flexibility when adding, removing, or reconfiguring nodes in the cluster. Without a centralized load balancer, the client must be manually updated or be able to discover and manage endpoint changes dynamically.
This trade-off is common in high-performance distributed systems that prioritize low latency and direct communication over automatic infrastructure abstraction.
Snapshot Reads
The .NET client now supports as-of snapshot reads directly on top-level client methods through a snapshotMs parameter.
For point reads:
using Kahuna.Client;
using Kahuna.Shared.KeyValue;
var client = new KahunaClient("https://node1:2071");
KahunaKeyValue latest = await client.GetKeyValue(
"users/000100",
KeyValueDurability.Persistent
);
KahunaKeyValue sameSnapshot = await client.GetKeyValue(
"users/000100",
KeyValueDurability.Persistent,
snapshotMs: latest.LastModified
);
LastModified is the Unix-epoch millisecond timestamp at which that revision was committed. It can be reused as a snapshot anchor for later reads.
The same snapshot parameter is also available on:
ExistsKeyValue(...)GetByBucket(...)ScanAllByPrefix(...)GetByRange(...)ScanByRange(...)
Ordered Range Reads
For ordered key spaces such as users/000001 through users/999999, you can now use the top-level client directly to read a bounded ordered slice:
using Kahuna.Client;
using Kahuna.Shared.KeyValue;
var client = new KahunaClient([
"https://node1:2071",
"https://node2:2071",
"https://node3:2071"
]);
List<KahunaKeyValue> page = await client.GetByRange(
prefix: "users",
startKey: "users/000100",
startInclusive: true,
endKey: "users/000200",
endInclusive: false,
limit: 100,
durability: KeyValueDurability.Persistent
);
foreach (KahunaKeyValue item in page)
Console.WriteLine($"{item.Key} -> {item.ValueAsString()}");
If you need transactional locking or interactive read/write behavior around the range read, use a transaction session:
using System.Text;
using Kahuna.Client;
using Kahuna.Shared.KeyValue;
var client = new KahunaClient([
"https://node1:2071",
"https://node2:2071",
"https://node3:2071"
]);
await using KahunaTransactionSession session = await client.StartTransactionSession(
new KahunaTransactionOptions
{
Locking = KeyValueTransactionLocking.Optimistic,
Timeout = 5000
}
);
KeyValueGetByRangePageResult page = await session.GetByRange(
prefix: "users",
startKey: "users/000100",
startInclusive: true,
endKey: "users/000200",
endInclusive: false,
limit: 100,
durability: KeyValueDurability.Persistent
);
foreach (KeyValueGetByBucketItem item in page.Items)
Console.WriteLine($"{item.Key} -> {Encoding.UTF8.GetString(item.Value)}");
This is the right read pattern when a key space is modeled as an ordered range instead of a single bucket. See Key-Range Sharding for the routing model and trade-offs.
For top-level client reads, snapshotMs pins the read to one historical snapshot. For transaction-session range reads, readTimestamp does the same thing at the session API boundary.
When readTimestamp is set, the range read behaves as a historical snapshot. It does not switch into read-your-own-writes mode just because the session has a transaction ID. If a key existed at T and was updated later, the read returns the version visible at T; keys inserted after T stay hidden.
For exact archived revisions, the client still exposes GetKeyValueRevision(...). Use that when you know the precise revision number; use snapshotMs when you want the value visible at a specific historical time.
Streaming Range Reads
When you want to stream a larger ordered range instead of materializing one bounded page, use ScanByRange(...):
await foreach (KahunaKeyValue item in client.ScanByRange(
prefix: "users",
startKey: "users/000100",
startInclusive: true,
endKey: "users/001000",
endInclusive: false,
pageSize: 128,
durability: KeyValueDurability.Persistent,
snapshotMs: 1718392012345
))
{
Console.WriteLine($"{item.Key} -> {item.ValueAsString()}");
}
This keeps fetching server-side pages behind the async sequence while preserving one historical snapshot when snapshotMs is non-zero. Large range scans can read keys that currently live only on disk without forcing every scanned key back into the in-memory cache.
Batch Key/Value Operations
The client also exposes batch methods for common key/value work:
SetManyKeyValues(...)DeleteManyKeyValues(...)GetManyKeyValues(...)ExistsManyKeyValues(...)
Example:
using Kahuna.Client;
using Kahuna.Shared.KeyValue;
List<KahunaKeyValue> setResults = await client.SetManyKeyValues([
new()
{
Key = "services/auth",
Value = System.Text.Encoding.UTF8.GetBytes("node1"),
ExpiresMs = 30000,
Flags = KeyValueFlags.Set,
Durability = KeyValueDurability.Persistent
},
new()
{
Key = "services/payments",
Value = System.Text.Encoding.UTF8.GetBytes("node2"),
ExpiresMs = 30000,
Flags = KeyValueFlags.Set,
Durability = KeyValueDurability.Persistent
}
]);
List<KahunaKeyValue> getResults = await client.GetManyKeyValues([
new() { Key = "services/auth", Durability = KeyValueDurability.Persistent },
new() { Key = "services/payments", Durability = KeyValueDurability.Persistent }
]);
Request item notes:
KahunaSetKeyValueRequestItemsupportsKey,Value,ExpiresMs,Flags,CompareValue,CompareRevision, andDurabilityKahunaDeleteKeyValueRequestItemsupportsKeyandDurabilityKahunaGetManyKeyValuesRequestItemsupportsKey, optionalRevision, andDurability
Register a Key Range
For ordered key spaces, the client also exposes RegisterKeyRange(...):
bool created = await client.RegisterKeyRange("users");
This registers a key space for range-based sharding so the cluster routes that space through range descriptors instead of the default hash-routed model.
Use this only for key spaces that are intentionally modeled as ordered ranges. See Key-Range Sharding for the routing trade-offs.
Transport Notes
Some client features currently require the gRPC transport:
GetManyKeyValues(...)is not available over the REST transportExistsManyKeyValues(...)is not available over the REST transportRegisterKeyRange(...)is not available over the REST transport
If you call those APIs through the REST transport, the client throws NotSupportedException.
Specify durability type
You can also specify the desired durability type when acquiring a lock:
using Kahuna.Client;
public async Task UpdateBalance(KahunaClient client, string userId)
{
// acquire a lock with persistent durability, ensuring that the lock state is
// replicated across all nodes in the Kahuna cluster
// in case of failure or network partition, the lock state is guaranteed to be durable
await using KahunaLock myLock = await client.GetOrCreateLock(
"balance-" + userId,
TimeSpan.FromSeconds(300), // lock for 5 mins
durability: LockDurability.Persistent
);
if (myLock.IsAcquired)
{
Console.WriteLine("Lock acquired with strong consistency!");
// implement exclusive logic here
}
else
{
Console.WriteLine("Someone else has the lock!");
}
// myLock is automatically released after leaving the method
}
Learn more about the supported durabilities.
Sequences: Usage & Examples
The .NET client exposes Kahuna's distributed sequencer for named, monotonically increasing values.
using Kahuna.Client;
using Kahuna.Shared.Sequences;
var client = new KahunaClient("https://localhost:8082");
KahunaSequence sequence = await client.CreateSequence(
"orders",
initialValue: 0,
increment: 1,
maxValue: null,
durability: SequenceDurability.Persistent
);
long orderId = await client.NextSequenceValue(
"orders",
idempotencyKey: "create-order-123"
);
KahunaSequenceRange range = await client.ReserveSequenceRange(
"orders",
count: 100,
idempotencyKey: "import-batch-456"
);
KahunaSequence? current = await client.GetSequence("orders");
bool deleted = await client.DeleteSequence("orders");
Use idempotency keys when retrying allocation requests after a timeout. If the original request was committed, retrying with the same idempotency key returns the original allocation instead of consuming a new value.
Key/Values: Usage & Examples
Basic Usage
...
Transactions
Using the C# client, developers can execute both Kahuna Scripts and interactive transactions, depending on what best suits their use case.
This flexibility allows for choosing between:
- Kahuna Scripts for atomic, server-side logic with minimal latency.
- Interactive transactions for full control using C# code and external libraries.
Developers can switch between both approaches as needed to balance performance, maintainability, and complexity.
Scripts
Kahuna Scripts can be loaded from their string representation and executed in C# like this:
const string script = """
let inventory_key = get @inventory_key
let requested_amount = get @requested_amount
let inventory = to_int(inventory_key)
let requested = to_int(requested_amount)
if current >= requested then
set inventory_key inventory - requested
return 1
else
return 0
end
""";
var result = await client.ExecuteKeyValueTransactionScript(
script,
null,
[
new() { Key = "@inventory_key", Value = userInventoryKey },
new() { Key = "@requested_amount", Value = "100" }
]
);
Console.WriteLine("Result={0}", result.FirstValueAsString);
The recommended way to execute scripts is to pass all dynamic values as parameters, rather than embedding them directly in the script. This allows the server to reuse the execution plan across different calls with different inputs, improving performance and preventing security issues such as script injection.
Avoid this:
await client.ExecuteKeyValueTransactionScript("SET " + key + " " + value);
Prefer this:
await client.ExecuteKeyValueTransactionScript(
"SET @key @value",
null,
[
new() { Key = "@key", Value = key },
new() { Key = "@value", Value = value }
]
);
This pattern leads to safer, faster, and more maintainable use of Kahuna Scripts.
Another good practice is to load scripts during an initialization process so they can be reused many times later. This reduces memory usage and helps the server reuse the execution plan, improving performance and lowering overhead:
public class SessionChecker
{
private readonly KahunaTransactionScript kahunaScript;
public SessionChecker(KahunaClient client)
{
const string myScript = """
let exists_key = exists @session_key
if exists_key then
extend @session_key @ttl_in_seconds
return 1
end
return 0
""";
kahunaScript = client.LoadTransactionScript(myScript);
}
public async Task<bool> CheckSession(string sessionKey, string ttlInSeconds)
{
var result = await kahunaScript.Run([
new() { Key = sessionKey, Value = ttlInSeconds }
]);
var extended = result.FirstValueAsString ?? "0";
return extended == "1";
}
}
By avoiding re-parsing and re-planning on every call, this approach makes script execution more efficient, especially in high-throughput scenarios. It also makes code easier to maintain by separating logic from runtime logic injection.
Interactive Transactions
With interactive transactions, developers can execute transactional flows directly from C# without the need to use Kahuna Scripts.
This gives programmers full control over the transaction logic using familiar language constructs, while still benefiting from Kahuna’s consistency guarantees, distributed coordination, and support for multi-key operations:
await using var session = await client.StartTransactionSession(
new() {
Locking = KeyValueTransactionLocking.Optimistic,
Timeout = 5000
}
);
var balance1 = await session.GetKeyValue(userA);
var balance2 = await session.GetKeyValue(userB);
if (balance1.ValueAsLong() >= 50)
{
await session.SetKeyValue(userA, balance1.ValueAsLong() - 50);
await session.SetKeyValue(userB, balance2.ValueAsLong() + 50);
}
await session.Commit();
In case of conflicts or encountering exclusive locks (under pessimistic locking), transactions will be aborted so they can be retried on the client side.
Two user-facing behaviors are worth knowing:
GetByBucket(...)inside a pessimistic session protects the whole bucket with a prefix lock, which blocks phantom inserts and conflicting writes under that prefix until the transaction finishes.GetByRange(...)inside a pessimistic session protects only the requested interval with a range lock, which is the better fit for large ordered key spaces.
The recommended approach is to use the built-in retry mechanism provided by Kahuna clients, which automatically retries aborted transactions using a short backoff interval, helping reduce contention while ensuring consistency and forward progress:
var txOptions = new KahunaTransactionOptions()
{
Locking = KeyValueTransactionLocking.Pessimistic,
Timeout = 5000
};
await client.RetryableTransaction(txOptions, async (session, cancellationToken) =>
{
var balance1 = await session.GetKeyValue(userA);
var balance2 = await session.GetKeyValue(userB);
if (balance1.ValueAsLong() >= 50)
{
await session.SetKeyValue(userA, balance1.ValueAsLong() - 50);
await session.SetKeyValue(userB, balance2.ValueAsLong() + 50);
}
await session.Commit();
});
Backup and Point-in-Time Restore
Start the target Kahuna server with --pitr-backup-dir before using backup operations. The catalog belongs to the node selected by the client, so use a stable endpoint when building or inspecting an incremental chain.
using Kahuna.Client;
using Kahuna.Shared.Communication.Rest;
var client = new KahunaClient("https://kahuna-1:8082");
KahunaBackupInfo full = await client.TakeCoordinatedBackupAsync();
KahunaBackupInfo incremental = await client.TakeIncrementalBackupAsync(
full.BackupId
);
List<KahunaBackupInfo> backups = await client.ListBackupsAsync();
List<KahunaBackupInfo> chain = await client.GetBackupChainAsync(
incremental.BackupId
);
Available methods:
| Method | Purpose |
|---|---|
TakeFullBackupAsync() | Create a full backup on the selected node |
TakeCoordinatedBackupAsync() | Create a full backup capped at a cluster-wide safe HLC timestamp |
TakeIncrementalBackupAsync(parentBackupId) | Append committed WAL changes to a backup chain |
ListBackupsAsync() | List manifests in the selected node's local catalog |
GetBackupChainAsync(leafBackupId) | Resolve and validate a chain from its full root through the selected leaf |
RestoreAsync(leafBackupId, targetDir, targetTimeMs) | Restore into a new directory on the selected server node |
Restore through the chain's natural end with targetTimeMs: 0:
KahunaRestoreResponse restored = await client.RestoreAsync(
leafBackupId: incremental.BackupId,
targetDir: "/var/lib/kahuna/restored",
targetTimeMs: 0
);
Console.WriteLine($"Applied {restored.EntriesApplied} WAL entries");
Console.WriteLine($"Restored to {restored.TargetDir}");
For point-in-time recovery, pass the target HLC physical component as Unix epoch milliseconds. targetDir refers to the server filesystem. The operation does not replace live state; start a fresh node with the restored directory.
See Backups and Point-in-Time Recovery for server setup, node bootstrap, and current replay limitations.