From foundationdb-dotnet-skills
Guides FoundationDB .NET transaction usage: retry loops (ReadAsync/WriteAsync/ReadWriteAsync), idempotency, 5-second limit, size limits, atomic mutations, snapshot reads, conflict ranges, and watches. Use when writing transaction handlers or debugging conflicts.
How this skill is triggered — by the user, by Claude, or both
Slash command
/foundationdb-dotnet-skills:foundationdb-transactionsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
FoundationDB gives you **serializable, ACID transactions** over the whole keyspace. The catch: a transaction may **conflict** and need to be retried, and it has hard limits (time and size). The .NET client handles retries for you via a **retry loop** — but only if you use it correctly. The single biggest source of bugs is writing a transaction handler that is **not safe to run more than once**.
FoundationDB gives you serializable, ACID transactions over the whole keyspace. The catch: a transaction may conflict and need to be retried, and it has hard limits (time and size). The .NET client handles retries for you via a retry loop — but only if you use it correctly. The single biggest source of bugs is writing a transaction handler that is not safe to run more than once.
If you are encoding keys/values inside the transaction, read the foundationdb-keys-and-layers skill too.
Don't manually BeginTransaction / CommitAsync in application code. Use the retryable methods on IFdbDatabase (or IFdbDatabaseProvider). Pick the narrowest one:
| Method | Transaction type | Use for |
|---|---|---|
db.ReadAsync(handler, ct) | IFdbReadOnlyTransaction | reads only; returns a result |
db.WriteAsync(handler, ct) | IFdbTransaction | mutations that return nothing (the handler may still read — its transaction is a full read/write one) |
db.ReadWriteAsync(handler, ct) | IFdbTransaction | mutations that must return a value out of the transaction |
The split between
WriteAsyncandReadWriteAsyncis about the return value, not about whether you read. Both hand you a fullIFdbTransaction.ReadWriteAsynchas no "returns nothing" overload — if your handler returns no value, useWriteAsync.
// READ
Book? book = await db.ReadAsync(async tr =>
{
var bytes = await tr.GetAsync(subspace.Key("D", id));
return bytes.IsNull ? null : CrystalJson.Deserialize<Book>(bytes);
}, ct);
// WRITE (no reads, nothing to return)
await db.WriteAsync(tr =>
{
tr.Set(subspace.Key("D", book.Id), FdbValue.ToJson(book));
}, ct);
// READ-MODIFY-WRITE (need a result and/or read before write)
long newBalance = await db.ReadWriteAsync(async tr =>
{
long current = (await tr.GetAsync(accountKey)).ToInt64();
long updated = current + amount;
tr.Set(accountKey, FdbValue.ToFixed64LittleEndian(updated));
return updated;
}, ct);
The retry loop commits for you (you never call CommitAsync inside the handler) and re-runs the handler on retryable errors until it succeeds, the CancellationToken fires, or a non-retryable error is thrown.
There is a state overload (db.ReadAsync(state, (tr, state) => …, ct)) that lets you pass captured data without allocating a closure — prefer it in hot paths.
The handler lambda can and will run multiple times. Treat it as a pure function of the database state.
❌ Never mutate external/global state inside the handler. No incrementing in-memory counters, no adding to caches/lists, no logging "done", no sending messages, no static field writes. On a retry, those side effects happen again — but the earlier attempt's database writes were discarded.
✅ Do all such work after the loop returns successfully:
// WRONG — _cache is mutated even on attempts that never commit
await db.WriteAsync(tr => { tr.Set(k, v); _cache[id] = book; }, ct);
// RIGHT — only touch external state after the transaction has committed
await db.WriteAsync(tr => tr.Set(k, v), ct);
_cache[id] = book;
The handler may read whatever it needs from the transaction; it just must not affect anything outside it. (See also the success callback overloads, which run once after a successful commit.)
| Limit | Value | Consequence |
|---|---|---|
| Transaction lifetime | 5 seconds | Long reads/range scans fail with past_version (error 1007). Don't iterate huge ranges in one tx. |
| Value size | 100,000 bytes | Split large blobs across keys (see FdbBlob). |
| Key size | 10,000 bytes | Keep tuple keys reasonable. |
| Total writes per tx | 10,000,000 bytes | Batch large imports across many transactions. |
For bulk operations that exceed these, use the Fdb.Bulk.* helpers (import/export/batch) instead of one giant transaction, and the FdbKey.Batched(...) helpers to split index ranges into chunks.
A range scan that might be large should be paged across transactions (resume from the last key's Successor()), not run as one 5-second read.
A read-write transaction conflicts if another transaction commits a write to a key this transaction read, between this transaction's read version and commit. The retry loop hides the retry, but conflicts cost latency. To reduce them:
Use atomic mutations instead of read-modify-write where possible — they don't create read conflicts:
tr.AtomicAdd64(counterKey, +1); // value stored as fixed little-endian 64-bit
tr.AtomicIncrement64(counterKey);
tr.AtomicDecrement64(counterKey, clearIfZero: true);
tr.AtomicMax(key, v); tr.AtomicMin(key, v);
tr.AtomicAnd/Or/Xor(key, mask);
(Counters stored for atomic add must be fixed-width little-endian: FdbValue.ToFixed64LittleEndian / Slice.FromFixed64.)
Snapshot reads (tr.Snapshot.GetAsync(...), tr.Snapshot.GetRange(...)) read without creating a read-conflict on those keys. Use them when a stale read is acceptable (e.g. counting shards, statistics). Don't use snapshot reads for values you then use to compute a write that needs consistency.
Sharding for write-hot keys: a single frequently-incremented key serializes all writers. Spread writes across random sub-keys and sum on read — exactly what FdbHighContentionCounter does.
You can add explicit conflict ranges with tr.AddConflictRange(begin, end, FdbConflictRangeType.Read|Write) when you need conflict behavior that differs from what your reads/writes imply (advanced).
tr.Watch(key, ct) returns an FdbWatch that completes when the key's value changes after the transaction commits. Use it for change notification without polling. Create the watch inside a transaction (the handler is async; there is no synchronous return overload):
FdbWatch watch = await db.ReadWriteAsync(
async tr => tr.Watch(signalKey, ct), // optionally read/set first, then return the watch
ct);
await watch; // resolves when signalKey's value changes after this tx commits
CancellationToken to Watch — not the transaction's own tr.Cancellation. The watch outlives the transaction, so binding it to the transaction's token is rejected.This is how real layers (e.g. a pub/sub firehose) push work between nodes without polling:
tr.AtomicIncrement32(subscriber.Key("WATCH")). AtomicIncrement guarantees the value changes (so the watch always fires) and never conflicts with other producers.await it outside the transaction, then loop and re-read.while (!ct.IsCancellationRequested)
{
var (batch, watch) = await db.ReadWriteAsync(async tr =>
{
var sub = await location.Resolve(tr);
// snapshot read: scanning the queue shouldn't conflict with producers
var msgs = await tr.Snapshot.GetRangeAsync(sub.Key("INBOX").ToRange(), FdbRangeOptions.WantAll.WithLimit(100));
if (msgs.Count == 0)
return ((FdbRangeChunk?) null, (FdbWatch?) tr.Watch(sub.Key("WATCH"), ct)); // outer token!
tr.ClearRange(msgs.First, FdbKey.Successor(msgs.Last)); // consume exactly what we read
return (msgs, (FdbWatch?) null);
}, ct);
if (watch != null) { await watch; continue; } // notified -> loop, re-read
// dispatch batch...
}
Order messages with commit-time VersionStamps so they sort in publish order across all producers: var stamp = tr.CreateVersionStamp(i); tr.SetVersionStampedKey(inbox.Key(stamp), payload); (see the keys/layers skill).
A Layer resolves its per-transaction State inside the handler and uses it there (see foundationdb-keys-and-layers). Two equivalent styles:
// (a) Layer helper methods resolve State for you:
await store.WriteAsync(db, (tr, state) => state.Insert(tr, book), ct);
// (b) Resolve manually to compose several layers atomically in one tx:
await db.WriteAsync(async tr =>
{
var books = await bookStore.Resolve(tr);
var index = await authorIndex.Resolve(tr);
books.Insert(tr, book);
index.Add(tr, book.Author, book.Id);
}, ct);
Composing layers in one transaction is the core advantage: all writes commit together or not at all.
The resolved
Stateis valid only for the transaction that produced it. Because the handler may retry, never hoistResolve(tr)out of the loop or stash theStatein a field.
FdbException carries an FdbError code. Retryable codes (conflicts, past_version, etc.) are handled by the retry loop automatically — don't catch and swallow them inside the handler.not_committed (1020) conflict is normal under contention; it's retried for you.FdbException inside the handler just to retry manually — that fights the loop.db.ReadAsync/WriteAsync/ReadWriteAsync (not manual begin/commit)?await-ing unrelated long work inside the handler (5-second budget)?States confined to the handler?npx claudepluginhub snowbanksdk/foundationdb-dotnet-client --plugin foundationdb-dotnet-skillsGuides building sophisticated FoundationDB layers with .NET: cluster internals, latency/throughput optimization, high-contention avoidance, and distributed patterns (change feeds, version-stamp logs).
Guides usage of Neo4j .NET Driver v6: IDriver lifecycle, DI registration, ExecutableQuery, async transactions, IResultCursor, value access, null safety, error handling, and common traps.
Explains ACID transaction guarantees with worked SQL examples. Useful when debugging data inconsistencies, designing transactional workflows, or evaluating database choices.