Skip to main content

Transactions

A transaction groups several operations into a single atomic unit: either all of them apply or none do. Transactions use optimistic concurrency, so a commit fails if another client changed the data you read. Use them to keep related data consistent under concurrent access.

Why transactions matter

When several clients modify related data at once, applying operations one at a time can lose updates, apply only some of a set, or leave the store in an inconsistent state.

A transaction removes these failure modes with an all-or-nothing guarantee:

All operations succeed together, or none are applied.

Start a transaction

BeginTransactionAsync creates a transaction context. Operations inside it are isolated from the store until you commit. The transaction is disposable, so bind it with await using to release it reliably.

await using var tx = await client.BeginTransactionAsync();

Read inside a transaction

Read with tx.GetAsync<T>. Reads are part of the transaction and typically inform a decision you make before writing. The value you read is also what the commit checks for conflicts.

var value = await tx.GetAsync<int>("keyA");

Write inside a transaction

Write with tx.PutAsync and tx.DeleteAsync. Writes are staged, not applied — they stay invisible to other clients until the commit succeeds.

await tx.PutAsync("keyA", 100);
await tx.DeleteAsync("keyB");
warning

Staged writes are not visible outside the transaction until commit. Do not expect another client — or another client instance of yours — to observe these changes before CommitAsync returns success. Within the same transaction, however, your own reads see your staged writes.

Commit

CommitAsync applies every staged operation atomically. It can fail if a conflict is detected, so always check the result.

var result = await tx.CommitAsync();

if (result.IsSuccess)
{
Console.WriteLine("Transaction committed");
}
else
{
Console.WriteLine("Transaction failed");
}
warning

A commit is not guaranteed to succeed. Under optimistic concurrency, CommitAsync fails when data you read changed before you committed. If you ignore the result and assume the write went through, your application silently proceeds on data that was never persisted. Always branch on result.IsSuccess.

Roll back

RollbackAsync discards every staged change. Nothing is applied to the store.

await tx.RollbackAsync();

This transaction reads two keys, computes new values, and commits both writes as one unit. Neither write is visible until the commit succeeds, so the two keys never diverge.

await using var tx = await client.BeginTransactionAsync();

var a = await tx.GetAsync<int>("keyA");
var b = await tx.GetAsync<int>("keyB");

await tx.PutAsync("keyA", a.Value + 5);
await tx.PutAsync("keyB", b.Value + 5);

await tx.CommitAsync();

Optimistic concurrency and conflicts

Transactions do not lock the data they read. Instead, the commit checks whether anything you read changed in the meantime. If it did, the commit fails rather than overwrite newer data. This is optimistic concurrency: you proceed assuming no conflict, and the store verifies that assumption at commit time.

The sequence below shows a conflict. The transaction reads keyA, an external write changes it, and the commit detects the mismatch and fails.

await using var tx = await client.BeginTransactionAsync();

var value = await tx.GetAsync<int>("keyA");

// External update happens here
await client.PutAsync("keyA", 500);

await tx.PutAsync("keyA", value.Value + 1);

var result = await tx.CommitAsync();

if (!result.IsSuccess)
{
Console.WriteLine("Transaction failed due to conflict");
}

The transaction read a stale value, an external update advanced it, and the commit refused to clobber the newer value. This is the protection working as intended: it prevents you from overwriting data you never saw.

tip

A conflict is expected, not exceptional. Treat a failed commit as a signal to reread the current data and retry the transaction, not as a fatal error. Cap the number of retries so a persistently contended key does not loop forever.

Deletes inside a transaction

A delete is staged like any other write, and your own later reads in the same transaction see its effect. Here the read after the delete reports "not found" inside the transaction, even before commit.

await using var tx = await client.BeginTransactionAsync();

await tx.DeleteAsync("keyB");

var inside = await tx.GetAsync<int>("keyB");

Console.WriteLine(inside.IsSuccess); // false inside TX

await tx.CommitAsync();

Transaction compared with batch

Both group operations, but only a transaction gives atomicity and conflict detection. Choose based on whether you need consistency.

FeatureBatchTransaction
PurposeGroup operationsEnsure consistency
ExecutionSequentialAtomic
On failurePartial success possibleAll-or-nothing
Conflict checkNoYes

Use a batch when the operations are independent and partial success is acceptable. Use a transaction when they must all land together.

When to use transactions

Reach for a transaction when several updates must succeed together, when you are modifying related data, when you need strong consistency, or when you want the store to detect conflicting concurrent writes for you.

Best practices

Keep transactions correct and fast with these habits.

  • Read the values you need before you write, so the commit can check them for conflicts.
  • Retry on a failed commit when a conflict is the likely cause, with a bounded retry count.
  • Keep transactions short-lived and minimize the number of operations inside them.
  • Keep long-running or unrelated logic outside the transaction.
  • Use a batch instead when you do not need consistency.

Next steps