Namespace: CrazyGoat\FoundationDB
FoundationDB transactions provide ACID guarantees with strict serializability. The PHP client handles the complexity of FoundationDB's optimistic concurrency control through automatic retry loops.
FoundationDB transactions have these key properties:
- ACID: Atomic, Consistent, Isolated, Durable
- Serializable: All transactions appear to execute in a serial order
- Optimistically Concurrent: Transactions proceed without locking; conflicts are detected at commit time
- Automatic Retry: The
transact()method automatically retries on transient failures
The primary way to use transactions is through the transact() method on Database:
use CrazyGoat\FoundationDB\FoundationDB as FDB;
use CrazyGoat\FoundationDB\Transaction;
FDB::apiVersion(730);
$db = FDB::open();
$result = $db->transact(function (Transaction $tr) {
$value = $tr->get('key')->await();
$tr->set('key', 'new-value');
return $value; // returned from transact()
});- Retry loop: The callback may be called multiple times if the transaction conflicts with another
- Return values: Whatever the callback returns is passed through from
transact() - Automatic commit: Commit happens automatically after the callback returns successfully
- Error handling:
FDBExceptionwith retryable errors triggers automatic retry via$tr->onError()
All read operations return Future objects. You must call ->await() to get the result:
// Get a single value — returns ?string (null if key doesn't exist)
$value = $tr->get(string|KeyConvertible $key): FutureValue;
$result = $value->await();
// Get a key using a selector — returns string
$key = $tr->getKey(KeySelector $selector): FutureKey;
$result = $key->await();
// Get the read version — returns int
$version = $tr->getReadVersion(): FutureInt64;
$result = $version->await();// Lazy iteration over a range — returns RangeResult (iterable)
$range = $tr->getRange(
string|KeySelector $begin,
string|KeySelector $end,
?RangeOptions $options = null
): RangeResult;
foreach ($range as $kv) {
echo $kv->key . ' = ' . $kv->value . "\n";
}
// Prefix-based range read
$range = $tr->getRangeStartsWith(string $prefix): RangeResult;
// Eager fetch — returns list<KeyValue>
$all = $tr->getRangeAll(
string|KeySelector $begin,
string|KeySelector $end,
?RangeOptions $options = null
): array;
// Eager prefix range — returns list<KeyValue>
$all = $tr->getRangeAllStartsWith(string $prefix): array;// Get estimated size of a range in bytes
$size = $tr->getEstimatedRangeSizeBytes(string $begin, string $end): FutureInt64;
// Get split points for parallel processing
$points = $tr->getRangeSplitPoints(string $begin, string $end, int $chunkSize): FutureKeyArray;
// Get storage server addresses for a key
$addresses = $tr->getAddressesForKey(string $key): FutureStringArray;Write operations are immediate (no Future returned):
// Set a key-value pair
$tr->set(string|KeyConvertible $key, string $value): void;
// Delete a single key
$tr->clear(string|KeyConvertible $key): void;
// Delete a range of keys [begin, end)
$tr->clearRange(string $begin, string $end): void;
// Delete all keys with a given prefix
$tr->clearRangeStartsWith(string $prefix): void;// Explicitly commit the transaction
$future = $tr->commit(): FutureVoid;
$future->await();
// Reset the transaction for reuse
$tr->reset(): void;
// Cancel the transaction
$tr->cancel(): void;
// Handle errors (used by the retry loop)
$future = $tr->onError(int $code): FutureVoid;
$future->await();- In
transact(), commit is automatic — you don't need to call it manually reset()clears all operations and allows reusing the transaction objectcancel()aborts the transaction immediatelyonError()implements the retry backoff logic for transient errors
// Set a specific read version
$tr->setReadVersion(int $version): void;
// Get the committed version (after successful commit)
$version = $tr->getCommittedVersion(): int;
// Get approximate transaction size in bytes
$size = $tr->getApproximateSize(): FutureInt64;
// Get the versionstamp (after commit) — returns the versionstamp key
$versionstamp = $tr->getVersionstamp(): FutureKey;Snapshot reads are conflict-free — they don't add read conflict ranges. This is useful for reads that shouldn't cause transaction conflicts:
$db->transact(function (Transaction $tr) {
// Snapshot read — no conflict range added
$value = $tr->snapshot()->get('frequently-read-key')->await();
// Regular read — adds conflict range
$important = $tr->get('important-key')->await();
// Selectively add conflict for keys you care about
$tr->addReadConflictKey('another-important-key');
});$tr->snapshot()returns aSnapshotobject (extendsReadTransaction)- Snapshot reads don't add read conflict ranges
- Useful for frequently-read data that changes often but doesn't need strict consistency
- You can mix snapshot and regular reads in the same transaction
Lifetime note: each
snapshot()call returns a freshSnapshot(it is not cached on the transaction). ASnapshotshares its parent's native handle and keeps the parentTransactionalive as long as it exists — but because the reference is one-directional, both objects are released as soon as they go out of scope andfdb_transaction_destroy()runs deterministically. Caching the snapshot on the transaction was removed in [#38]: a reference cycle deferred native-handle destruction to the cycle collector, which leaked handles in long-running workers with the collector disabled.
For read-only operations, use readTransact() — it automatically uses snapshot reads:
$value = $db->readTransact(function (Snapshot $snap) {
return $snap->get('key')->await();
});- Uses snapshot reads automatically (no conflicts)
- No commit needed (faster)
- Still retries on errors
- Simpler code for pure read operations
You can manually add conflict ranges for fine-grained control:
// Add a read conflict range [begin, end)
$tr->addReadConflictRange(string $begin, string $end): void;
// Add a write conflict range [begin, end)
$tr->addWriteConflictRange(string $begin, string $end): void;
// Add a read conflict for a single key
$tr->addReadConflictKey(string $key): void;
// Add a write conflict for a single key
$tr->addWriteConflictKey(string $key): void;- Adding conflicts for keys read via snapshot
- Extending conflict ranges beyond what was actually read
- Implementing custom conflict detection logic
For cases where you need more control, create transactions manually:
// Create a transaction
$tr = $db->createTransaction();
try {
$tr->set('key', 'value');
$tr->commit()->await();
} catch (FDBException $e) {
// Handle error manually
if ($e->fdbCode === 1020) { // not_committed
// Conflict — retry with backoff
$tr->onError($e->fdbCode)->await();
// Retry logic here...
}
} finally {
// Transaction is destroyed when garbage collected
unset($tr);
}- Custom retry logic
- Long-running transactions with periodic commits
- Fine-grained error handling
- Integration with external transaction coordinators
Configure transaction behavior using the fluent options API:
$db->transact(function (Transaction $tr) {
$tr->options()
->setTimeout(5000) // 5 second timeout
->setRetryLimit(3) // Max 3 retries
->setPriorityBatch(); // Lower priority (batch operations)
// ... your operations ...
});| Option | Description |
|---|---|
setTimeout(int $ms) |
Transaction timeout in milliseconds |
setRetryLimit(int $limit) |
Maximum number of retries |
setMaxRetryDelay(int $ms) |
Maximum retry delay |
setPriorityBatch() |
Lower priority for batch operations |
setPrioritySystemImmediate() |
Higher priority for urgent operations |
setReadYourWritesDisable() |
Disable read-your-writes optimization |
setSnapshotReadDangerous() |
Enable dangerous snapshot read mode |
See options.md for the complete list of transaction options.
Any object implementing KeyConvertible can be used as a key:
interface KeyConvertible {
public function asFoundationDbKey(): string;
}Subspace— packs tuple prefixDirectorySubspace— directory layer subspace
use CrazyGoat\FoundationDB\Subspace;
$users = new Subspace(['users']);
$db->transact(function (Transaction $tr) use ($users) {
// Subspace implements KeyConvertible
$tr->set($users->pack([42]), 'Alice');
// Can also use the packed key directly
$value = $tr->get($users->pack([42]))->await();
});- Keep transactions short: Long transactions increase conflict probability
- Use
transact(): Let the library handle retry logic - Use
readTransact()for reads: Automatic snapshot reads, no commit overhead - Use snapshot reads wisely: For data that changes often but doesn't need strict consistency
- Handle idempotency: Your callback may run multiple times — ensure it's safe to retry
- Avoid external side effects: Don't perform I/O or external mutations inside
transact() - Use subspaces: Organize keys logically and avoid key collisions
Common error codes you might encounter:
| Code | Constant | Description |
|---|---|---|
| 1020 | not_committed |
Transaction conflict — will be retried automatically |
| 1021 | commit_unknown_result |
Commit status unknown — may need application-level handling |
| 1023 | transaction_cancelled |
Transaction was cancelled |
| 1025 | transaction_timed_out |
Transaction exceeded timeout |
| 1031 | future_released |
Future was released before completion |
The transact() and readTransact() methods handle retryable errors automatically. For manual transactions, use $tr->onError($code)->await() to implement proper retry backoff.
FoundationDB enforces hard limits on the bytes you can store, and the PHP client validates them up front so failures happen at the call site instead of as an opaque error on commit().
| Payload | Limit | FDB error code |
|---|---|---|
| Key (read or write) | 10,000 bytes (10 KB) | 2102 |
| Value | 100,000 bytes (100 KB) | 2103 |
| Aggregated transaction | 10,000,000 bytes (10 MB) by default | 2101 |
The transaction-size limit is aggregate and is still reported by libfdb_c on commit() (PHP-side does not pre-compute the running total). Key and value limits are checked on every call so you get them at the offending line rather than at the end of the retry loop.
use CrazyGoat\FoundationDB\KeyValueLimits;
assert(KeyValueLimits::MAX_KEY_SIZE === 10_000);
assert(KeyValueLimits::MAX_VALUE_SIZE === 100_000);
assert(KeyValueLimits::MAX_FFI_LENGTH === 2_147_483_647);An oversize write throws \InvalidArgumentException immediately, with the expected length and the limit in the message:
$oversize = str_repeat('a', 10_001);
try {
$tr->set($oversize, 'value');
} catch (\InvalidArgumentException $e) {
// "FoundationDB key exceeds maximum size: 10001 bytes (limit is 10000 bytes)"
}The same applies to clear(), clearRange(), atomicOp(), watch(), get(), getKey(), getRange(), addReadConflictRange(), addWriteConflictRange() and setOption().
Note:
transact()retries onFDBExceptiononly. A guard rejection is treated as a programmer error, not a transient conflict, so it propagates out oftransact()immediately — the retry loop will not silently re-attempt an oversize write.
libfdb_c's length parameters are 32-bit int; PHP strlen() is 64-bit. Pre-validation
keeps a > 2 GB payload from silently truncating at the C boundary. The defensive
FFI guard (KeyValueLimits::MAX_FFI_LENGTH) fires only for pathological inputs —
every realistic FoundationDB payload is well below it.
The retry loop in transact(), readTransact(), and the four
watch* helpers (watch, getAndWatch, setAndWatch,
clearAndWatch) is bounded by an opt-in, process-wide retry
budget that the application configures via FoundationDB:
| Setting | Default | Purpose |
|---|---|---|
FoundationDB::defaultTransactionRetryLimit(int) |
0 |
Max on_error().await() retries per call. 0 = unbounded. |
FoundationDB::defaultTransactionTimeoutSeconds(float) |
0.0 |
Max wall-clock seconds per call. 0.0 = unbounded. |
Both ceilings are independent. Whichever is hit first throws
CrazyGoat\FoundationDB\TransactionRetryLimitExceededException
synchronously, with the actual attempt count and elapsed wall-clock
seconds.
The default of 0 for both — unbounded — preserves the
historical while (true) semantics: the loop relies on
fdb_transaction_on_error() to eventually bubble a non-retryable
error back to PHP. A persistent conflict workload can therefore
spin indefinitely under the default. To opt in:
use CrazyGoat\FoundationDB\FoundationDB as FDB;
FDB::apiVersion(730);
// At process startup, before the first transact() call:
FDB::defaultTransactionRetryLimit(50); // up to 50 retries per call
FDB::defaultTransactionTimeoutSeconds(5.0); // ...or 5 seconds, whichever comes firstIf defaultTransactionRetryLimit(-1) or
defaultTransactionTimeoutSeconds(-0.5) is set, the call throws
\InvalidArgumentException synchronously — a typo cannot silently
disable the ceiling. Every call to transact() (or any of the
helpers above) then has a deterministic upper bound:
$db = FDB::open();
try {
$db->transact(function ($tr) {
// ...write/read ...
});
} catch (\CrazyGoat\FoundationDB\TransactionRetryLimitExceededException $e) {
// $e->attempts — number of on_error retries consumed
// $e->elapsedSeconds — wall-clock seconds since the call started
// $e->getMessage() — distinguishes the boundary that was crossed
// ("wall-clock limit exceeded" vs
// "attempt limit exceeded").
error_log(sprintf(
'Retry ceiling reached: %d attempts after %.3fs',
$e->attempts,
$e->elapsedSeconds,
));
}This ceiling is library-level — it wraps the retry loop in the
PHP helper layer. It is distinct from FDB's own per-transaction
options (TransactionOptions::setRetryLimit(int),
TransactionOptions::setTimeout(int),
TransactionOptions::setMaxRetryDelay(int)), which operate inside
the native transaction. Both layers cooperate: the application
typically wants to set FDB's per-transaction ceiling tighter than
the PHP-side budget, but the PHP budget is the outer guarantee
that something will throw, even if FDB's own retries were
disabled or exhausted.
FoundationDB::reset() clears both ceilings back to 0 (along
with the API version and database cache) — useful for tests that
mutate retry policy and need to leave the process in a clean state.