From 5f6aac80f266e57201eee78fa51258b14952a3df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ha=C5=82as?= Date: Mon, 30 Mar 2026 15:23:19 +0200 Subject: [PATCH 1/2] Change atomic operations to accept int instead of raw bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add(), max(), min() now accept int — pack('P', ...) is done internally - bitAnd(), bitOr(), bitXor() now accept int — no manual byte encoding - Add getInt() to ReadTransaction and Database for reading integer values - byteMax(), byteMin(), compareAndClear() keep string params (raw bytes) - atomicOp() keeps string param for low-level access - Update HighContentionAllocator to use new int signature - Update integration tests to use new API - Update docs/atomic-operations.md and examples/atomic-operations.php - Update README.md atomic operations section --- README.md | 28 +- docs/atomic-operations.md | 142 ++++++---- examples/atomic-operations.php | 253 ++++++------------ src/Database.php | 25 +- src/Directory/HighContentionAllocator.php | 2 +- src/ReadTransaction.php | 29 ++ src/Transaction.php | 24 +- tests/Integration/DatabaseConvenienceTest.php | 57 ++-- 8 files changed, 280 insertions(+), 280 deletions(-) diff --git a/README.md b/README.md index 5c089bf..e7673ed 100644 --- a/README.md +++ b/README.md @@ -150,17 +150,23 @@ $dir->remove($db, ['app', 'orders']); ### Atomic Operations ```php -$db->transact(function (Transaction $tr) { - // Increment a little-endian counter - $tr->add('counter', pack('P', 1)); - - // Bitwise operations - $tr->bitXor('flag', pack('C', 1)); - $tr->bitOr('permissions', pack('C', 0b00001100)); - - // Compare and clear - $tr->compareAndClear('temp', pack('P', 0)); -}); +// Integer atomic ops accept int directly — no pack() needed +$db->add('counter', 1); +$db->add('counter', 10); +$db->max('high_score', 999); +$db->min('response_time', 42); + +// Bitwise operations +$db->bitOr('flags', 0b00001100); +$db->bitAnd('flags', 0b11110011); +$db->bitXor('flags', 0b00000001); + +// Read integer values — no unpack() needed +$count = $db->getInt('counter'); // ?int +$flags = $db->getInt('flags'); // ?int + +// Compare and clear (raw bytes) +$db->compareAndClear('temp', 'expected_value'); ``` ### Snapshot Reads diff --git a/docs/atomic-operations.md b/docs/atomic-operations.md index 881bd45..604e281 100644 --- a/docs/atomic-operations.md +++ b/docs/atomic-operations.md @@ -6,76 +6,115 @@ Atomic operations modify values without reading them first, avoiding read-write conflicts. They're performed as part of a transaction but don't require a read conflict range. -## Available Operations - -| Method | MutationType | Description | -|--------|-------------|-------------| -| `add($key, $param)` | Add | Interprets both values as little-endian integers and adds them | -| `bitAnd($key, $param)` | BitAnd | Bitwise AND | -| `bitOr($key, $param)` | BitOr | Bitwise OR | -| `bitXor($key, $param)` | BitXor | Bitwise XOR | -| `max($key, $param)` | Max | Keeps the lexicographically greater value | -| `min($key, $param)` | Min | Keeps the lexicographically lesser value | -| `byteMax($key, $param)` | ByteMax | Byte-string maximum | -| `byteMin($key, $param)` | ByteMin | Byte-string minimum | -| `compareAndClear($key, $param)` | CompareAndClear | Clears key if current value equals param | -| `setVersionstampedKey($key, $value)` | SetVersionstampedKey | Sets key with versionstamp | -| `setVersionstampedValue($key, $param)` | SetVersionstampedValue | Sets value with versionstamp | +## Integer Operations + +The `add()`, `max()`, `min()`, `bitAnd()`, `bitOr()`, and `bitXor()` methods accept `int` parameters directly. The library handles the little-endian encoding internally. + +| Method | Param Type | Description | +|--------|-----------|-------------| +| `add($key, int $param)` | `int` | Adds the integer to the stored value | +| `max($key, int $param)` | `int` | Keeps the larger value | +| `min($key, int $param)` | `int` | Keeps the smaller value | +| `bitAnd($key, int $param)` | `int` | Bitwise AND | +| `bitOr($key, int $param)` | `int` | Bitwise OR | +| `bitXor($key, int $param)` | `int` | Bitwise XOR | + +## Byte Operations + +These operate on raw byte strings and keep `string` parameters: + +| Method | Param Type | Description | +|--------|-----------|-------------| +| `byteMax($key, string $param)` | `string` | Byte-string maximum | +| `byteMin($key, string $param)` | `string` | Byte-string minimum | +| `compareAndClear($key, string $param)` | `string` | Clears key if current value equals param | +| `setVersionstampedKey($key, string $value)` | `string` | Sets key with versionstamp | +| `setVersionstampedValue($key, string $param)` | `string` | Sets value with versionstamp | ## Counters -The most common use case for atomic operations is implementing counters: +The most common use case — no `pack()` / `unpack()` needed: ```php use CrazyGoat\FoundationDB\Transaction; -// Increment a counter by 1 -$db->transact(function (Transaction $tr) { - $tr->add('page_views', pack('P', 1)); // P = unsigned 64-bit LE -}); +// Increment a counter +$db->add('page_views', 1); // Increment by 10 +$db->add('page_views', 10); + +// Read the counter — returns int directly +$count = $db->getInt('page_views'); +echo "Page views: {$count}\n"; // 11 + +// Inside a transaction $db->transact(function (Transaction $tr) { - $tr->add('page_views', pack('P', 10)); + $tr->add('page_views', 1); + $count = $tr->getInt('page_views'); }); +``` + +## Reading Integer Values + +Use `getInt()` to read values stored by integer atomic operations: + +```php +// Database level +$value = $db->getInt('counter'); // ?int — null if key doesn't exist -// Read the counter +// Transaction level $db->transact(function (Transaction $tr) { - $raw = $tr->get('page_views')->await(); - $count = unpack('P', $raw)[1]; - echo "Page views: {$count}\n"; + $value = $tr->getInt('counter'); // ?int +}); + +// Snapshot level +$db->readTransact(function ($snap) { + $value = $snap->getInt('counter'); // ?int }); ``` -**Note:** `pack('P', $n)` creates an unsigned 64-bit little-endian integer. +`getInt()` decodes the stored bytes as an unsigned 64-bit little-endian integer. It returns `null` if the key does not exist. If the stored value is shorter than 8 bytes, it is zero-padded. ## Bitwise Operations ```php -$db->transact(function (Transaction $tr) { - // Toggle a flag - $tr->bitXor('flags', pack('C', 0b00000001)); - - // Set specific bits - $tr->bitOr('permissions', pack('C', 0b00001100)); - - // Clear specific bits - $tr->bitAnd('permissions', pack('C', 0b11110011)); -}); +// Set specific bits +$db->bitOr('permissions', 0b00001100); + +// Clear specific bits +$db->bitAnd('permissions', 0b11110011); + +// Toggle a flag +$db->bitXor('flags', 0b00000001); + +// Read the result +$flags = $db->getInt('flags'); +``` + +## Max and Min + +```php +// Track high score — only updates if new value is larger +$db->max('high_score', 999); + +// Track minimum — only updates if new value is smaller +$db->min('response_time_ms', 42); ``` ## Compare and Clear +Atomically clears a key only if its current value matches the expected value: + ```php -$db->transact(function (Transaction $tr) { - // Clear key only if its value is the zero sentinel - $tr->compareAndClear('temp_lock', pack('P', 0)); -}); +$db->compareAndClear('temp_lock', 'expected_value'); ``` +Note: `compareAndClear()` uses `string` parameters since it compares raw byte values. + ## Generic atomicOp() -For cases where you need to use a mutation type dynamically: +For cases where you need raw byte-level control or a mutation type dynamically: ```php use CrazyGoat\FoundationDB\Enum\MutationType; @@ -83,29 +122,34 @@ use CrazyGoat\FoundationDB\Enum\MutationType; $tr->atomicOp(MutationType::Add, 'counter', pack('P', 1)); ``` +The low-level `atomicOp()` always takes `string` parameters. + ## Database-Level Convenience Methods -The Database class provides convenience methods that automatically wrap operations in a transaction: +All integer atomic operations are available directly on the Database: ```php -$db->add('counter', pack('P', 1)); -$db->bitXor('flag', pack('C', 1)); -$db->compareAndClear('temp', pack('P', 0)); -// Also: bitAnd, bitOr, max, min +$db->add('counter', 1); +$db->max('high_score', 999); +$db->min('lowest', 1); +$db->bitOr('flags', 0b00000001); +$db->bitAnd('flags', 0b11111110); +$db->bitXor('flags', 0b00000001); +$db->getInt('counter'); // ?int ``` ## Versionstamped Operations -Versionstamped operations allow you to embed the commit version into keys or values: +Versionstamped operations embed the commit version into keys or values: ```php $db->transact(function (Transaction $tr) { // Key with versionstamp placeholder $tr->setVersionstampedKey($keyWithPlaceholder, 'value'); - + // Value with versionstamp placeholder $tr->setVersionstampedValue('key', $valueWithPlaceholder); - + // Get the versionstamp after commit $vs = $tr->getVersionstamp(); // FutureKey, await after commit }); diff --git a/examples/atomic-operations.php b/examples/atomic-operations.php index bb48375..39832e8 100644 --- a/examples/atomic-operations.php +++ b/examples/atomic-operations.php @@ -4,11 +4,10 @@ * Atomic Operations Example * * This script demonstrates FoundationDB atomic operations: - * - Counter with add() - * - Reading counter value - * - Bitwise operations (bitXor for toggle) - * - compareAndClear - * - Clean up + * - Counter with add() and getInt() + * - Bitwise operations (bitOr, bitAnd, bitXor) + * - max() and min() for keeping extremes + * - compareAndClear for conditional deletion */ declare(strict_types=1); @@ -30,220 +29,136 @@ $db->clearRangeStartsWith($prefix); echo "Done.\n\n"; -// Counter with add() +// Counter with add() — now takes int directly, no pack() needed echo "--- Atomic Counter with add() ---\n"; -echo "Atomic operations are performed without reading the value first,\n"; -echo "making them safe for concurrent access.\n\n"; +echo "add() accepts an int — no more pack('P', ...) boilerplate!\n\n"; -// Initialize counter -$db->set($prefix . 'counter', pack('J', 0)); // 64-bit unsigned integer +$db->set($prefix . 'counter', pack('P', 0)); echo "Initialized counter to 0\n"; -// Atomically add values -$db->add($prefix . 'counter', pack('J', 10)); +$db->add($prefix . 'counter', 10); echo "Added 10 (atomic)\n"; -$db->add($prefix . 'counter', pack('J', 5)); +$db->add($prefix . 'counter', 5); echo "Added 5 (atomic)\n"; -$db->add($prefix . 'counter', pack('J', 100)); +$db->add($prefix . 'counter', 100); echo "Added 100 (atomic)\n"; -// Read the counter value -$counterValue = $db->get($prefix . 'counter'); -if ($counterValue !== null) { - $unpacked = unpack('J', $counterValue); - if ($unpacked !== false) { - echo "\nFinal counter value: {$unpacked[1]}\n"; - } -} +// Read the counter with getInt() — no more unpack() needed +$value = $db->getInt($prefix . 'counter'); +echo "\nFinal counter value: {$value}\n"; echo "Expected: 115 (10 + 5 + 100 = 115)\n\n"; -// Demonstrating concurrent safety +// Concurrent safety echo "--- Concurrent Safety Demonstration ---\n"; -echo "Simulating concurrent increments:\n"; - -// Reset counter -$db->set($prefix . 'concurrent_counter', pack('J', 0)); +$db->set($prefix . 'concurrent', pack('P', 0)); -// Simulate multiple concurrent transactions adding to the same counter $additions = [1, 2, 3, 4, 5]; foreach ($additions as $amount) { $db->transact(function (Transaction $tr) use ($prefix, $amount): void { - // Each transaction atomically adds without reading - $tr->add($prefix . 'concurrent_counter', pack('J', $amount)); + $tr->add($prefix . 'concurrent', $amount); }); echo " Transaction added {$amount}\n"; } -$finalValue = $db->get($prefix . 'concurrent_counter'); -if ($finalValue !== null) { - $unpacked = unpack('J', $finalValue); - if ($unpacked !== false) { - echo "\nFinal value after concurrent adds: {$unpacked[1]}\n"; - echo "Expected: 15 (1+2+3+4+5 = 15)\n"; - echo "Note: With atomic operations, all increments are preserved!\n"; - } -} -echo "\n"; +$finalValue = $db->getInt($prefix . 'concurrent'); +echo "\nFinal value: {$finalValue}\n"; +echo "Expected: 15 (1+2+3+4+5)\n\n"; -// Bitwise operations +// Bitwise operations — also take int now echo "--- Bitwise Operations ---\n"; -// bitOr - sets bits -$db->set($prefix . 'flags', pack('C', 0b00000000)); -echo "Initialized flags: 00000000 (binary)\n"; +$db->set($prefix . 'flags', pack('P', 0)); +echo "Initialized flags: 0\n"; -$db->bitOr($prefix . 'flags', pack('C', 0b00000001)); // Set bit 0 -echo "After bitOr(00000001): set bit 0\n"; +$db->bitOr($prefix . 'flags', 0b00000001); // Set bit 0 +echo "After bitOr(0b00000001): set bit 0\n"; -$db->bitOr($prefix . 'flags', pack('C', 0b00000100)); // Set bit 2 -echo "After bitOr(00000100): set bit 2\n"; +$db->bitOr($prefix . 'flags', 0b00000100); // Set bit 2 +echo "After bitOr(0b00000100): set bit 2\n"; -$db->bitOr($prefix . 'flags', pack('C', 0b00010000)); // Set bit 4 -echo "After bitOr(00010000): set bit 4\n"; +$db->bitOr($prefix . 'flags', 0b00010000); // Set bit 4 +echo "After bitOr(0b00010000): set bit 4\n"; -$flagsValue = $db->get($prefix . 'flags'); -if ($flagsValue !== null) { - $unpacked = unpack('C', $flagsValue); - if ($unpacked !== false) { - echo "Final flags: " . sprintf('%08b', $unpacked[1]) . " (binary) = {$unpacked[1]} (decimal)\n"; - echo "Expected: 00010101 = 21\n"; - } -} -echo "\n"; +$flags = $db->getInt($prefix . 'flags'); +echo "Final flags: " . sprintf('%08b', $flags) . " = {$flags}\n"; +echo "Expected: 00010101 = 21\n\n"; -// bitAnd - clears bits (using mask) +// bitAnd — clear specific bits echo "--- bitAnd for Clearing Bits ---\n"; -$db->set($prefix . 'mask_test', pack('C', 0b11111111)); +$db->set($prefix . 'mask', pack('P', 0b11111111)); echo "Initialized: 11111111\n"; -// To clear bit 1, AND with 11111101 -$db->bitAnd($prefix . 'mask_test', pack('C', 0b11111101)); -$maskValue = $db->get($prefix . 'mask_test'); -if ($maskValue !== null) { - $unpacked = unpack('C', $maskValue); - if ($unpacked !== false) { - echo "After bitAnd(11111101): " . sprintf('%08b', $unpacked[1]) . "\n"; - } -} -echo "\n"; - -// bitXor - toggle bits -echo "--- bitXor for Toggling Bits ---\n"; -$db->set($prefix . 'toggle', pack('C', 0b00000000)); -echo "Initialized: 00000000\n"; - -// Toggle bit 0 -$db->bitXor($prefix . 'toggle', pack('C', 0b00000001)); -$toggle1 = $db->get($prefix . 'toggle'); -if ($toggle1 !== null) { - $unpacked = unpack('C', $toggle1); - if ($unpacked !== false) { - echo "After bitXor(00000001): " . sprintf('%08b', $unpacked[1]) . " (bit 0 ON)\n"; - } -} +$db->bitAnd($prefix . 'mask', 0b11111101); // Clear bit 1 +$maskValue = $db->getInt($prefix . 'mask'); +echo "After bitAnd(11111101): " . sprintf('%08b', $maskValue) . "\n"; +echo "Expected: 11111101\n\n"; -// Toggle bit 0 again (turns it off) -$db->bitXor($prefix . 'toggle', pack('C', 0b00000001)); -$toggle2 = $db->get($prefix . 'toggle'); -if ($toggle2 !== null) { - $unpacked = unpack('C', $toggle2); - if ($unpacked !== false) { - echo "After bitXor(00000001): " . sprintf('%08b', $unpacked[1]) . " (bit 0 OFF)\n"; - } -} +// bitXor — toggle bits +echo "--- bitXor for Toggling ---\n"; +$db->set($prefix . 'toggle', pack('P', 0)); +echo "Initialized: 0\n"; -// Toggle multiple bits -$db->bitXor($prefix . 'toggle', pack('C', 0b00000110)); // Toggle bits 1 and 2 -$toggle3 = $db->get($prefix . 'toggle'); -if ($toggle3 !== null) { - $unpacked = unpack('C', $toggle3); - if ($unpacked !== false) { - echo "After bitXor(00000110): " . sprintf('%08b', $unpacked[1]) . " (bits 1,2 ON)\n"; - } -} -echo "\n"; +$db->bitXor($prefix . 'toggle', 0b00000001); +echo "After bitXor(1): " . sprintf('%08b', $db->getInt($prefix . 'toggle')) . " (bit 0 ON)\n"; -// compareAndClear -echo "--- compareAndClear ---\n"; -echo "Atomically clears a key only if its value matches the expected value.\n\n"; +$db->bitXor($prefix . 'toggle', 0b00000001); +echo "After bitXor(1): " . sprintf('%08b', $db->getInt($prefix . 'toggle')) . " (bit 0 OFF)\n"; -$db->set($prefix . 'conditional', 'expected_value'); -echo "Set key to 'expected_value'\n"; +$db->bitXor($prefix . 'toggle', 0b00000110); +echo "After bitXor(110): " . sprintf('%08b', $db->getInt($prefix . 'toggle')) . " (bits 1,2 ON)\n\n"; -// Try to clear with wrong value (should not clear) -$db->compareAndClear($prefix . 'conditional', 'wrong_value'); -$stillThere = $db->get($prefix . 'conditional'); -$stillThereStr = $stillThere === null ? 'null (cleared)' : "'{$stillThere}' (still present)"; -echo "After compareAndClear('wrong_value'): " . $stillThereStr . "\n"; +// max and min — also take int +echo "--- max() and min() ---\n"; -// Now clear with correct value -$db->compareAndClear($prefix . 'conditional', 'expected_value'); -$nowGone = $db->get($prefix . 'conditional'); -echo "After compareAndClear('expected_value'): " . ($nowGone === null ? 'null (cleared)' : "'{$nowGone}'") . "\n"; -echo "\n"; +$db->set($prefix . 'high_score', pack('P', 50)); +echo "Initialized high_score to 50\n"; -// max and min operations -echo "--- max and min Operations ---\n"; +$db->max($prefix . 'high_score', 30); +echo "After max(30): " . $db->getInt($prefix . 'high_score') . " (30 < 50, no change)\n"; -// max - keeps the larger value -$db->set($prefix . 'max_test', pack('J', 50)); -echo "Initialized max_test to 50\n"; +$db->max($prefix . 'high_score', 100); +echo "After max(100): " . $db->getInt($prefix . 'high_score') . " (100 > 50, updated)\n\n"; -$db->max($prefix . 'max_test', pack('J', 30)); // 30 < 50, no change -echo "After max(30): value stays 50 (30 < 50)\n"; +$db->set($prefix . 'lowest', pack('P', 50)); +echo "Initialized lowest to 50\n"; -$db->max($prefix . 'max_test', pack('J', 100)); // 100 > 50, updates -echo "After max(100): value becomes 100 (100 > 50)\n"; +$db->min($prefix . 'lowest', 100); +echo "After min(100): " . $db->getInt($prefix . 'lowest') . " (100 > 50, no change)\n"; -$maxValue = $db->get($prefix . 'max_test'); -if ($maxValue !== null) { - $unpacked = unpack('J', $maxValue); - if ($unpacked !== false) { - echo "Final max value: {$unpacked[1]}\n"; - } -} +$db->min($prefix . 'lowest', 30); +echo "After min(30): " . $db->getInt($prefix . 'lowest') . " (30 < 50, updated)\n\n"; -// min - keeps the smaller value -$db->set($prefix . 'min_test', pack('J', 50)); -echo "\nInitialized min_test to 50\n"; +// compareAndClear — still uses string (raw byte comparison) +echo "--- compareAndClear ---\n"; +$db->set($prefix . 'conditional', 'expected_value'); +echo "Set key to 'expected_value'\n"; -$db->min($prefix . 'min_test', pack('J', 100)); // 100 > 50, no change -echo "After min(100): value stays 50 (100 > 50)\n"; +$db->compareAndClear($prefix . 'conditional', 'wrong_value'); +$stillThere = $db->get($prefix . 'conditional'); +echo "After compareAndClear('wrong_value'): " . ($stillThere === null ? 'cleared' : "'{$stillThere}'") . "\n"; -$db->min($prefix . 'min_test', pack('J', 30)); // 30 < 50, updates -echo "After min(30): value becomes 30 (30 < 50)\n"; +$db->compareAndClear($prefix . 'conditional', 'expected_value'); +$nowGone = $db->get($prefix . 'conditional'); +echo "After compareAndClear('expected_value'): " . ($nowGone === null ? 'cleared' : "'{$nowGone}'") . "\n\n"; -$minValue = $db->get($prefix . 'min_test'); -if ($minValue !== null) { - $unpacked = unpack('J', $minValue); - if ($unpacked !== false) { - echo "Final min value: {$unpacked[1]}\n"; - } -} -echo "\n"; - -// Practical example: visit counter -echo "--- Practical Example: Visit Counter ---\n"; -$db->set($prefix . 'visits', pack('J', 0)); -echo "Initialized visit counter to 0\n"; - -// Simulate page visits -$visits = [1, 1, 1, 1, 1]; // 5 visits -foreach ($visits as $i => $_) { - $db->add($prefix . 'visits', pack('J', 1)); - echo " Visit " . ($i + 1) . " recorded\n"; -} +// getInt() returns null for missing keys +echo "--- getInt() with missing keys ---\n"; +$missing = $db->getInt($prefix . 'nonexistent'); +echo "getInt('nonexistent'): " . ($missing === null ? 'null' : $missing) . "\n\n"; + +// Practical example +echo "--- Practical Example: Page View Counter ---\n"; +$db->set($prefix . 'views', pack('P', 0)); -$totalVisits = $db->get($prefix . 'visits'); -if ($totalVisits !== null) { - $unpacked = unpack('J', $totalVisits); - if ($unpacked !== false) { - echo "\nTotal visits: {$unpacked[1]}\n"; - } +for ($i = 1; $i <= 5; $i++) { + $db->add($prefix . 'views', 1); + echo " Visit {$i} recorded\n"; } +echo "\nTotal views: " . $db->getInt($prefix . 'views') . "\n"; + // Cleanup echo "\n--- Cleanup ---\n"; $db->clearRangeStartsWith($prefix); diff --git a/src/Database.php b/src/Database.php index 9d7385e..a033425 100644 --- a/src/Database.php +++ b/src/Database.php @@ -266,42 +266,55 @@ public function clearAndWatch(string $key): FutureVoid } } - public function add(string $key, string $param): void + /** + * Read a key and decode its value as a little-endian unsigned 64-bit integer. + * + * This is the counterpart to add(), max(), min() and other integer-based atomic operations. + * + * @return ?int null if the key does not exist + */ + public function getInt(string|KeyConvertible $key): ?int + { + /** @var ?int */ + return $this->transact(fn (Transaction $tr): ?int => $tr->getInt($key)); + } + + public function add(string $key, int $param): void { $this->transact(function (Transaction $tr) use ($key, $param): void { $tr->add($key, $param); }); } - public function bitAnd(string $key, string $param): void + public function bitAnd(string $key, int $param): void { $this->transact(function (Transaction $tr) use ($key, $param): void { $tr->bitAnd($key, $param); }); } - public function bitOr(string $key, string $param): void + public function bitOr(string $key, int $param): void { $this->transact(function (Transaction $tr) use ($key, $param): void { $tr->bitOr($key, $param); }); } - public function bitXor(string $key, string $param): void + public function bitXor(string $key, int $param): void { $this->transact(function (Transaction $tr) use ($key, $param): void { $tr->bitXor($key, $param); }); } - public function max(string $key, string $param): void + public function max(string $key, int $param): void { $this->transact(function (Transaction $tr) use ($key, $param): void { $tr->max($key, $param); }); } - public function min(string $key, string $param): void + public function min(string $key, int $param): void { $this->transact(function (Transaction $tr) use ($key, $param): void { $tr->min($key, $param); diff --git a/src/Directory/HighContentionAllocator.php b/src/Directory/HighContentionAllocator.php index 78c8c85..3b10608 100644 --- a/src/Directory/HighContentionAllocator.php +++ b/src/Directory/HighContentionAllocator.php @@ -65,7 +65,7 @@ public function allocate(Transaction $tr): string $tr->add( $this->counters->pack([$start]), - pack('P', 1), + 1, ); $candidate = $start + random_int(0, $window - 1); diff --git a/src/ReadTransaction.php b/src/ReadTransaction.php index 1f2dfed..d38a53b 100644 --- a/src/ReadTransaction.php +++ b/src/ReadTransaction.php @@ -172,6 +172,35 @@ public function getRangeStartsWith( ); } + /** + * Read a key and decode its value as a little-endian unsigned 64-bit integer. + * + * This is the counterpart to Transaction::add(), ::max(), ::min() and other + * integer-based atomic operations. + * + * @return ?int null if the key does not exist + */ + public function getInt(string|KeyConvertible $key): ?int + { + $raw = $this->get($key)->await(); + + if ($raw === null) { + return null; + } + + if (strlen($raw) < 8) { + $raw = str_pad($raw, 8, "\x00"); + } + + $unpacked = unpack('P', $raw); + + if ($unpacked === false) { + throw new \RuntimeException('Failed to decode integer value for key'); + } + + return $unpacked[1]; + } + /** @internal */ public function getPointer(): CData { diff --git a/src/Transaction.php b/src/Transaction.php index 7ebf5e8..7253048 100644 --- a/src/Transaction.php +++ b/src/Transaction.php @@ -152,34 +152,34 @@ public function atomicOp(MutationType $type, string $key, string $param): void ); } - public function add(string $key, string $param): void + public function add(string $key, int $param): void { - $this->atomicOp(MutationType::Add, $key, $param); + $this->atomicOp(MutationType::Add, $key, pack('P', $param)); } - public function bitAnd(string $key, string $param): void + public function bitAnd(string $key, int $param): void { - $this->atomicOp(MutationType::BitAnd, $key, $param); + $this->atomicOp(MutationType::BitAnd, $key, pack('P', $param)); } - public function bitOr(string $key, string $param): void + public function bitOr(string $key, int $param): void { - $this->atomicOp(MutationType::BitOr, $key, $param); + $this->atomicOp(MutationType::BitOr, $key, pack('P', $param)); } - public function bitXor(string $key, string $param): void + public function bitXor(string $key, int $param): void { - $this->atomicOp(MutationType::BitXor, $key, $param); + $this->atomicOp(MutationType::BitXor, $key, pack('P', $param)); } - public function max(string $key, string $param): void + public function max(string $key, int $param): void { - $this->atomicOp(MutationType::Max, $key, $param); + $this->atomicOp(MutationType::Max, $key, pack('P', $param)); } - public function min(string $key, string $param): void + public function min(string $key, int $param): void { - $this->atomicOp(MutationType::Min, $key, $param); + $this->atomicOp(MutationType::Min, $key, pack('P', $param)); } public function byteMax(string $key, string $param): void diff --git a/tests/Integration/DatabaseConvenienceTest.php b/tests/Integration/DatabaseConvenienceTest.php index d8a4ed0..fb0fc76 100644 --- a/tests/Integration/DatabaseConvenienceTest.php +++ b/tests/Integration/DatabaseConvenienceTest.php @@ -131,44 +131,39 @@ public function addAtomicOperation(): void { $this->getDatabase()->set('test/conv/add', pack('P', 10)); - $this->getDatabase()->add('test/conv/add', pack('P', 5)); + $this->getDatabase()->add('test/conv/add', 5); - $result = unpack('P', (string) $this->getDatabase()->get('test/conv/add')); - self::assertIsArray($result); - self::assertSame(15, $result[1]); + self::assertSame(15, $this->getDatabase()->getInt('test/conv/add')); } #[Test] public function bitAndAtomicOperation(): void { - $this->getDatabase()->set('test/conv/band', "\xFF\x0F"); + $this->getDatabase()->set('test/conv/band', pack('P', 0xFF0F)); - $this->getDatabase()->bitAnd('test/conv/band', "\x0F\x0F"); + $this->getDatabase()->bitAnd('test/conv/band', 0x0F0F); - $result = $this->getDatabase()->get('test/conv/band'); - self::assertSame("\x0F\x0F", $result); + self::assertSame(0x0F0F, $this->getDatabase()->getInt('test/conv/band')); } #[Test] public function bitOrAtomicOperation(): void { - $this->getDatabase()->set('test/conv/bor', "\xF0\x00"); + $this->getDatabase()->set('test/conv/bor', pack('P', 0xF000)); - $this->getDatabase()->bitOr('test/conv/bor', "\x0F\x0F"); + $this->getDatabase()->bitOr('test/conv/bor', 0x0F0F); - $result = $this->getDatabase()->get('test/conv/bor'); - self::assertSame("\xFF\x0F", $result); + self::assertSame(0xFF0F, $this->getDatabase()->getInt('test/conv/bor')); } #[Test] public function bitXorAtomicOperation(): void { - $this->getDatabase()->set('test/conv/bxor', "\xFF\xFF"); + $this->getDatabase()->set('test/conv/bxor', pack('P', 0xFFFF)); - $this->getDatabase()->bitXor('test/conv/bxor', "\x0F\x0F"); + $this->getDatabase()->bitXor('test/conv/bxor', 0x0F0F); - $result = $this->getDatabase()->get('test/conv/bxor'); - self::assertSame("\xF0\xF0", $result); + self::assertSame(0xF0F0, $this->getDatabase()->getInt('test/conv/bxor')); } #[Test] @@ -176,11 +171,9 @@ public function maxAtomicOperation(): void { $this->getDatabase()->set('test/conv/max', pack('P', 10)); - $this->getDatabase()->max('test/conv/max', pack('P', 20)); + $this->getDatabase()->max('test/conv/max', 20); - $result = unpack('P', (string) $this->getDatabase()->get('test/conv/max')); - self::assertIsArray($result); - self::assertSame(20, $result[1]); + self::assertSame(20, $this->getDatabase()->getInt('test/conv/max')); } #[Test] @@ -188,11 +181,9 @@ public function maxAtomicOperationKeepsExistingWhenLarger(): void { $this->getDatabase()->set('test/conv/max2', pack('P', 30)); - $this->getDatabase()->max('test/conv/max2', pack('P', 5)); + $this->getDatabase()->max('test/conv/max2', 5); - $result = unpack('P', (string) $this->getDatabase()->get('test/conv/max2')); - self::assertIsArray($result); - self::assertSame(30, $result[1]); + self::assertSame(30, $this->getDatabase()->getInt('test/conv/max2')); } #[Test] @@ -200,11 +191,9 @@ public function minAtomicOperation(): void { $this->getDatabase()->set('test/conv/min', pack('P', 20)); - $this->getDatabase()->min('test/conv/min', pack('P', 10)); + $this->getDatabase()->min('test/conv/min', 10); - $result = unpack('P', (string) $this->getDatabase()->get('test/conv/min')); - self::assertIsArray($result); - self::assertSame(10, $result[1]); + self::assertSame(10, $this->getDatabase()->getInt('test/conv/min')); } #[Test] @@ -212,11 +201,15 @@ public function minAtomicOperationKeepsExistingWhenSmaller(): void { $this->getDatabase()->set('test/conv/min2', pack('P', 5)); - $this->getDatabase()->min('test/conv/min2', pack('P', 30)); + $this->getDatabase()->min('test/conv/min2', 30); - $result = unpack('P', (string) $this->getDatabase()->get('test/conv/min2')); - self::assertIsArray($result); - self::assertSame(5, $result[1]); + self::assertSame(5, $this->getDatabase()->getInt('test/conv/min2')); + } + + #[Test] + public function getIntReturnsNullForMissingKey(): void + { + self::assertNull($this->getDatabase()->getInt('test/conv/nonexistent')); } #[Test] From 57ce77da4f91508fd06908a8687a3b258a966ca3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ha=C5=82as?= Date: Mon, 30 Mar 2026 15:27:40 +0200 Subject: [PATCH 2/2] Fix package name in README and docs to crazy-goat/fdb-php --- README.md | 2 +- docs/getting-started.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e7673ed..9c25ef7 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ A full-featured [FoundationDB](https://www.foundationdb.org/) client for PHP, bu ## Installation ```bash -composer require crazy-goat/foundationdb +composer require crazy-goat/fdb-php ``` Make sure `libfdb_c.so` is installed and accessible. On Ubuntu/Debian: diff --git a/docs/getting-started.md b/docs/getting-started.md index 4c4107d..9ce6f96 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -44,7 +44,7 @@ A `docker-compose.yml` file is available in this repository for easy local devel Install the library via Composer: ```bash -composer require crazy-goat/foundationdb +composer require crazy-goat/fdb-php ``` ## Verifying PHP Extensions