From 2b37ac5f1af0b96f971636b79b68f9ddfef17342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ha=C5=82as?= Date: Tue, 8 Sep 2026 22:32:59 +0200 Subject: [PATCH] feat(#90): add getTotalCost() and getTagThrottledDuration() --- .gitignore | 1 + CHANGELOG.md | 11 + docs/transactions.md | 6 + src/Future/FutureDouble.php | 32 +++ src/NativeClient.php | 3 + src/ReadTransaction.php | 26 +++ .../TransactionIntrospectionTest.php | 76 +++++++ tests/Unit/FutureDoubleTest.php | 210 ++++++++++++++++++ 8 files changed, 365 insertions(+) create mode 100644 src/Future/FutureDouble.php create mode 100644 tests/Integration/TransactionIntrospectionTest.php create mode 100644 tests/Unit/FutureDoubleTest.php diff --git a/.gitignore b/.gitignore index d7948e5..ec3d64b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ vendor/ composer.lock .phpunit.cache/ fdb.cluster +.tyci/ diff --git a/CHANGELOG.md b/CHANGELOG.md index dab79bf..1cdebe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ ## [Unreleased] ### Added +- [#90] `ReadTransaction::getTotalCost()` and + `ReadTransaction::getTagThrottledDuration()` (backed by + `fdb_transaction_get_total_cost` and + `fdb_transaction_get_tag_throttled_duration`), exposing the transaction + introspection needed to reason about cost-based and tag throttling. The + latter resolves to a double, so `fdb_future_get_double` was bound through + the new `CrazyGoat\FoundationDB\Future\FutureDouble` future type. Both + methods are available on `Transaction` and `Snapshot`. Unit tests in + `tests/Unit/FutureDoubleTest.php`; integration tests in + `tests/Integration/TransactionIntrospectionTest.php`; + `docs/transactions.md` updated. - [#98] Bound `fdb_future_get_bool`, the result accessor for boolean-resolving futures, through the new `CrazyGoat\FoundationDB\Future\FutureBool` future type (a prerequisite for the blob granule API). Also added diff --git a/docs/transactions.md b/docs/transactions.md index f91bca9..f8e11ab 100644 --- a/docs/transactions.md +++ b/docs/transactions.md @@ -170,6 +170,12 @@ $size = $tr->getApproximateSize(): FutureInt64; // Get the versionstamp (after commit) — returns the versionstamp key $versionstamp = $tr->getVersionstamp(): FutureKey; + +// Get the transaction's accumulated cost (cluster cost units) +$cost = $tr->getTotalCost(): FutureInt64; // available on Transaction and Snapshot + +// Seconds this transaction has been throttled by tag throttling +$duration = $tr->getTagThrottledDuration(): FutureDouble; // available on Transaction and Snapshot ``` --- diff --git a/src/Future/FutureDouble.php b/src/Future/FutureDouble.php new file mode 100644 index 0000000..4d430ce --- /dev/null +++ b/src/Future/FutureDouble.php @@ -0,0 +1,32 @@ +resolved) { + return $this->cachedResult; + } + + $this->blockUntilReady(); + + $out = $this->client->fdb->new('double'); + $this->client->checkError( + $this->client->fdb->fdb_future_get_double($this->fpointer, FFI::addr($out)), + ); + + $this->cachedResult = $out->cdata; + $this->releaseMemory(); + $this->resolved = true; + + return $this->cachedResult; + } +} diff --git a/src/NativeClient.php b/src/NativeClient.php index dfe1d6a..2e0a32e 100644 --- a/src/NativeClient.php +++ b/src/NativeClient.php @@ -46,6 +46,7 @@ final class NativeClient fdb_bool_t fdb_future_is_ready(FDBFuture* f); fdb_error_t fdb_future_get_error(FDBFuture* f); fdb_error_t fdb_future_get_int64(FDBFuture* f, int64_t* out); + fdb_error_t fdb_future_get_double(FDBFuture* f, double* out); fdb_error_t fdb_future_get_bool(FDBFuture* f, fdb_bool_t* out); fdb_error_t fdb_future_get_key(FDBFuture* f, const char** out_key, int* out_key_length); fdb_error_t fdb_future_get_value( @@ -135,6 +136,8 @@ final class NativeClient FDBFuture* fdb_transaction_commit(FDBTransaction* tr); fdb_error_t fdb_transaction_get_committed_version(FDBTransaction* tr, int64_t* version); FDBFuture* fdb_transaction_get_approximate_size(FDBTransaction* tr); + FDBFuture* fdb_transaction_get_total_cost(FDBTransaction* tr); + FDBFuture* fdb_transaction_get_tag_throttled_duration(FDBTransaction* tr); FDBFuture* fdb_transaction_get_versionstamp(FDBTransaction* tr); FDBFuture* fdb_transaction_watch(FDBTransaction* tr, const char* key_name, int key_name_length); FDBFuture* fdb_transaction_on_error(FDBTransaction* tr, fdb_error_t error); diff --git a/src/ReadTransaction.php b/src/ReadTransaction.php index 8fc0687..161820b 100644 --- a/src/ReadTransaction.php +++ b/src/ReadTransaction.php @@ -5,6 +5,7 @@ namespace CrazyGoat\FoundationDB; use CrazyGoat\FoundationDB\Enum\StreamingMode; +use CrazyGoat\FoundationDB\Future\FutureDouble; use CrazyGoat\FoundationDB\Future\FutureInt64; use CrazyGoat\FoundationDB\Future\FutureKey; use CrazyGoat\FoundationDB\Future\FutureKeyArray; @@ -63,6 +64,31 @@ public function getReadVersion(): FutureInt64 ); } + /** + * Total accumulated cost of the transaction so far, in the cluster's + * cost units (as used by cost-based throttling and the 10,000,000-unit + * per-transaction cost limit). + */ + public function getTotalCost(): FutureInt64 + { + return new FutureInt64( + $this->client->fdb->fdb_transaction_get_total_cost($this->tpointer), + $this->client, + ); + } + + /** + * Number of seconds this transaction has been throttled due to tag + * throttling so far. + */ + public function getTagThrottledDuration(): FutureDouble + { + return new FutureDouble( + $this->client->fdb->fdb_transaction_get_tag_throttled_duration($this->tpointer), + $this->client, + ); + } + public function getEstimatedRangeSizeBytes(string $begin, string $end): FutureInt64 { $beginLength = KeyValueLimits::assertValidRangeEndpoint($begin); diff --git a/tests/Integration/TransactionIntrospectionTest.php b/tests/Integration/TransactionIntrospectionTest.php new file mode 100644 index 0000000..40f6be4 --- /dev/null +++ b/tests/Integration/TransactionIntrospectionTest.php @@ -0,0 +1,76 @@ +getDatabase(); + + $db->transact(static function (Transaction $tr): void { + $before = $tr->getTotalCost()->await(); + self::assertGreaterThanOrEqual(0, $before); + + for ($i = 0; $i < 10; $i++) { + $tr->set("test/introspection/key$i", str_repeat('v', 100)); + } + + $after = $tr->getTotalCost()->await(); + self::assertGreaterThanOrEqual($before, $after); + }); + } + + #[Test] + public function totalCostIsAvailableOnSnapshots(): void + { + $db = $this->getDatabase(); + + $cost = $db->transact( + static fn (Transaction $tr): int => $tr->snapshot()->getTotalCost()->await(), + ); + + self::assertGreaterThanOrEqual(0, $cost); + } + + #[Test] + public function tagThrottledDurationIsZeroOnAnUnthrottledCluster(): void + { + $db = $this->getDatabase(); + + $db->transact(static function (Transaction $tr): void { + $tr->set('test/introspection/throttle', 'value'); + self::assertSame(0.0, $tr->getTagThrottledDuration()->await()); + }); + } + + #[Test] + public function tagThrottledDurationIsAvailableOnSnapshots(): void + { + $db = $this->getDatabase(); + + $duration = $db->transact( + static fn (Transaction $tr): float => $tr->snapshot()->getTagThrottledDuration()->await(), + ); + + self::assertSame(0.0, $duration); + } +} diff --git a/tests/Unit/FutureDoubleTest.php b/tests/Unit/FutureDoubleTest.php new file mode 100644 index 0000000..6ff64b9 --- /dev/null +++ b/tests/Unit/FutureDoubleTest.php @@ -0,0 +1,210 @@ +fdb_phpunit_stub_set_error(0); + } + + #[Test] + public function awaitReturnsTheDoubleResolvedByFdbFutureGetDouble(): void + { + $this->setStubDouble(0.0); + self::assertSame(0.0, $this->makeFuture()->await()); + + $this->setStubDouble(12.345); + self::assertSame(12.345, $this->makeFuture()->await()); + } + + #[Test] + public function awaitCachesTheResultAcrossCalls(): void + { + $this->setStubDouble(7.5); + $future = $this->makeFuture(); + + self::assertSame(7.5, $future->await()); + + // Even if the stub value changed underneath, the cached result wins. + $this->setStubDouble(1.25); + self::assertSame(7.5, $future->await()); + } + + #[Test] + public function isErrorReportsFalseForASuccessfulFuture(): void + { + $this->setStubDouble(0.0); + self::assertFalse($this->makeFuture()->isError()); + } + + #[Test] + public function isErrorIsCachedAndSurvivesMemoryRelease(): void + { + $this->setStubDouble(0.0); + $future = $this->makeFuture(); + + self::assertSame(0.0, $future->await()); + + // After await() the future's memory is released; the stub now returns + // garbage from fdb_future_get_error(). isError() must answer from the + // state captured before the release, not re-query the handle. + self::assertFalse($future->isError()); + } + + #[Test] + public function isErrorReportsTrueForAFutureInErrorState(): void + { + $this->setStubError(1); + self::assertTrue($this->makeFuture()->isError()); + } + + // -- Stub library ------------------------------------------------------ + + private static function buildStub(): FFI + { + $source = <<<'C' + static int g_is_error = 0; + static double g_double_value = 0.0; + static int g_released = 0; + void fdb_future_destroy(void* f) { (void)f; } + void fdb_future_release_memory(void* f) { (void)f; g_released = 1; } + void fdb_future_cancel(void* f) { (void)f; } + int fdb_future_block_until_ready(void* f) { (void)f; return 0; } + int fdb_future_is_ready(void* f) { (void)f; return 1; } + // Mimics the real client: querying the error code after the + // future's memory has been released is undefined behavior — the + // stub returns garbage (non-zero) to catch that path. + int fdb_future_get_error(void* f) { (void)f; return (g_is_error || g_released) ? 1020 : 0; } + int fdb_future_get_double(void* f, double* out) + { + (void)f; + *out = g_double_value; + return g_is_error ? 1020 : 0; + } + void fdb_phpunit_stub_set_error(int v) { g_is_error = v; g_released = 0; } + void fdb_phpunit_stub_set_double(double v) { g_double_value = v; g_released = 0; } + C; + + $header = <<<'C' + typedef struct FDB_future { unsigned char _opaque; } FDBFuture; + void fdb_future_destroy(FDBFuture* f); + void fdb_future_release_memory(FDBFuture* f); + void fdb_future_cancel(FDBFuture* f); + int fdb_future_block_until_ready(FDBFuture* f); + int fdb_future_is_ready(FDBFuture* f); + int fdb_future_get_error(FDBFuture* f); + int fdb_future_get_double(FDBFuture* f, double* out); + void fdb_phpunit_stub_set_error(int v); + void fdb_phpunit_stub_set_double(double v); + C; + + if (!extension_loaded('ffi')) { + self::markTestSkipped('ext-ffi is not available'); + } + + $cacheKey = md5($source . $header . PHP_VERSION . PHP_OS_FAMILY); + $libraryPath = sys_get_temp_dir() . '/fdb-php-phpunit-stub-' . $cacheKey . '.so'; + $sourcePath = sys_get_temp_dir() . '/fdb-php-phpunit-stub-' . $cacheKey . '.c'; + + if (!is_file($libraryPath)) { + file_put_contents($sourcePath, $source); + + $flags = PHP_OS_FAMILY === 'Darwin' ? '-dynamiclib' : '-shared'; + $command = sprintf( + 'cc %s -fPIC -o %s %s 2>&1', + $flags, + escapeshellarg($libraryPath), + escapeshellarg($sourcePath), + ); + exec($command, $outputLines, $exitCode); + + if ($exitCode !== 0) { + self::markTestSkipped(sprintf( + 'Cannot compile the FDB future stub (%s): %s', + $command, + implode("\n", $outputLines), + )); + } + } + + try { + return FFI::cdef($header, $libraryPath); + } catch (\Throwable $e) { + self::markTestSkipped('Cannot load the FDB future stub: ' . $e->getMessage()); + } + } + + private function setStubDouble(float $value): void + { + $stub = self::$stub; + \assert($stub instanceof FFI); + /** @phpstan-ignore-next-line method.notFound — dynamic FFI binding to the compiled stub */ + $stub->fdb_phpunit_stub_set_double($value); + } + + private function setStubError(int $value): void + { + $stub = self::$stub; + \assert($stub instanceof FFI); + /** @phpstan-ignore-next-line method.notFound — dynamic FFI binding to the compiled stub */ + $stub->fdb_phpunit_stub_set_error($value); + } + + private function makeFuture(): FutureDouble + { + $stub = self::$stub; + \assert($stub instanceof FFI); + + $nativeClient = (new \ReflectionClass(NativeClient::class))->newInstanceWithoutConstructor(); + $this->initializeReadOnly($nativeClient, 'fdb', $stub); + + $future = (new \ReflectionClass(FutureDouble::class))->newInstanceWithoutConstructor(); + $this->initializeReadOnly($future, 'fpointer', $stub->new('FDBFuture*')); + $this->initializeReadOnly($future, 'client', $nativeClient); + + return $future; + } + + private function initializeReadOnly(object $object, string $property, mixed $value): void + { + $declaringClass = (new \ReflectionProperty($object, $property))->getDeclaringClass()->getName(); + + \Closure::bind( + static function (object $target, string $name, mixed $val): void { + $target->$name = $val; + }, + null, + $declaringClass, + )($object, $property, $value); + } +}