From 7f4d31602600f4ad0fec6434e8b6e3362b6a5f23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ha=C5=82as?= Date: Wed, 9 Sep 2026 07:17:41 +0200 Subject: [PATCH] feat(#94): add getClientVersion, getServerProtocol and network thread completion hook --- CHANGELOG.md | 14 ++ docs/advanced.md | 16 ++ src/Database.php | 19 ++ src/FoundationDB.php | 29 +++ src/Future/FutureUInt64.php | 40 ++++ src/NativeClient.php | 55 +++++ tests/Integration/DatabaseMonitoringTest.php | 17 ++ tests/Integration/NetworkLifecycleTest.php | 24 +++ tests/Unit/FutureUInt64Test.php | 204 ++++++++++++++++++ tests/Unit/NativeClientCompletionHookTest.php | 165 ++++++++++++++ 10 files changed, 583 insertions(+) create mode 100644 src/Future/FutureUInt64.php create mode 100644 tests/Unit/FutureUInt64Test.php create mode 100644 tests/Unit/NativeClientCompletionHookTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 27d6a84..a3134a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,20 @@ ## [Unreleased] ### Added +- [#94] Client/cluster introspection: `FoundationDB::getClientVersion()` + (backed by `fdb_get_client_version`, the version/build of the loaded + `libfdb_c`), `Database::getServerProtocol()` (backed by + `fdb_database_get_server_protocol` through the new + `CrazyGoat\FoundationDB\Future\FutureUInt64` future type and the newly + bound `fdb_future_get_uint64` accessor), and + `FoundationDB::onNetworkThreadCompletion(callable)` for flushing + traces/metrics at shutdown. For safety the completion hook is invoked on + the PHP main thread from `NativeClient::stopNetwork()` after the network + thread has been joined — PHP is never executed on the FDB network thread. + Unit tests in `tests/Unit/FutureUInt64Test.php` and + `tests/Unit/NativeClientCompletionHookTest.php`; integration tests in + `tests/Integration/DatabaseMonitoringTest.php` and + `tests/Integration/NetworkLifecycleTest.php`; `docs/advanced.md` updated. - [#91] `ReadTransaction::getMappedRange()` — single-round-trip index lookups backed by `fdb_transaction_get_mapped_range` and `fdb_future_get_mappedkeyvalue_array` (the latter through the new diff --git a/docs/advanced.md b/docs/advanced.md index 8f10c4b..e39964a 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -55,6 +55,22 @@ $status = json_decode($statusJson, true); // Or get the parsed array directly (consistent with AdminClient::getClusterStatus()) $status = $db->getClientStatus(asArray: true); + +// Client library version of the loaded libfdb_c (useful in bug reports +// and in multi-version-client setups) +echo FoundationDB::getClientVersion(); + +// Protocol version spoken by the cluster — lets you detect whether the +// loaded client library can talk to it +echo $db->getServerProtocol(); + +// Run a callable once when the FDB network thread stops at process shutdown +// (useful for flushing traces/metrics). The callable runs on the PHP main +// thread, after the network thread has been joined — never on the network +// thread itself. +FoundationDB::onNetworkThreadCompletion(function (): void { + // flush metrics/traces here +}); ``` ## Connection Strings diff --git a/src/Database.php b/src/Database.php index 83aa395..ec671f0 100644 --- a/src/Database.php +++ b/src/Database.php @@ -396,6 +396,25 @@ public function getMainThreadBusyness(): float return $busyness; } + /** + * Get the protocol version spoken by the cluster this database is + * connected to (backed by `fdb_database_get_server_protocol`). + * + * Lets a client detect the cluster's protocol version and therefore + * whether the loaded client library can talk to it. Called with + * `expected_version = 0`, meaning "no expectation" — the future resolves + * to the protocol version currently in use by the cluster. + */ + public function getServerProtocol(): int + { + $future = new Future\FutureUInt64( + $this->client->fdb->fdb_database_get_server_protocol($this->dpointer, 0), + $this->client, + ); + + return $future->await(); + } + /** * Get the client status of the database. * diff --git a/src/FoundationDB.php b/src/FoundationDB.php index 1bf00f8..41daddf 100644 --- a/src/FoundationDB.php +++ b/src/FoundationDB.php @@ -109,6 +109,35 @@ public static function getMaxApiVersion(): int return NativeClient::getInstance()->fdb->fdb_get_max_api_version(); } + /** + * Get the version/build string of the loaded `libfdb_c` client library + * (backed by `fdb_get_client_version`). This is what you want to include + * in bug reports and to disambiguate multi-version-client setups. + */ + public static function getClientVersion(): string + { + // PHP FFI converts `const char*` return values to strings natively. + $version = NativeClient::getInstance()->fdb->fdb_get_client_version(); + \assert(\is_string($version)); + + return $version; + } + + /** + * Register a callable to be invoked once when the FDB network thread + * stops (i.e. when the network is stopped at process shutdown or via + * an explicit `NativeClient::stopNetwork()` call). Useful for flushing + * traces/metrics at shutdown. + * + * NOTE: the callable runs on the PHP main thread, strictly after the + * FDB network thread has been joined — PHP must never execute on the + * network thread itself. See `NativeClient::onNetworkThreadCompletion()`. + */ + public static function onNetworkThreadCompletion(callable $hook): void + { + NativeClient::getInstance()->onNetworkThreadCompletion($hook); + } + /** * Configure the default per-transaction retry-attempt ceiling used * by `Database::transact()`, `Database::readTransact()`, diff --git a/src/Future/FutureUInt64.php b/src/Future/FutureUInt64.php new file mode 100644 index 0000000..94439a2 --- /dev/null +++ b/src/Future/FutureUInt64.php @@ -0,0 +1,40 @@ +resolved) { + return $this->cachedResult; + } + + $this->blockUntilReady(); + + $out = $this->client->fdb->new('uint64_t'); + $this->client->checkError( + $this->client->fdb->fdb_future_get_uint64($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 8191aa6..27db6b4 100644 --- a/src/NativeClient.php +++ b/src/NativeClient.php @@ -94,6 +94,7 @@ final class NativeClient fdb_error_t fdb_setup_network(); fdb_error_t fdb_run_network(); fdb_error_t fdb_stop_network(); + const char* fdb_get_client_version(); void fdb_future_destroy(FDBFuture* f); void fdb_future_release_memory(FDBFuture* f); @@ -102,6 +103,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_uint64(FDBFuture* f, uint64_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); @@ -121,6 +123,7 @@ final class NativeClient void fdb_database_destroy(FDBDatabase* d); double fdb_database_get_main_thread_busyness(FDBDatabase* d); FDBFuture* fdb_database_get_client_status(FDBDatabase* d); + FDBFuture* fdb_database_get_server_protocol(FDBDatabase* d, uint64_t expected_version); fdb_error_t fdb_database_set_option(FDBDatabase* d, int option, const void* value, int value_length); fdb_error_t fdb_database_create_transaction(FDBDatabase* d, FDBTransaction** out_transaction); FDBFuture* fdb_database_reboot_worker( @@ -260,6 +263,21 @@ final class NativeClient /** @var \FFI\CData|null Handle returned by dlopen('libfdb_c.so'), closed in stopNetwork(). */ private ?CData $fdbLibraryHandle = null; + /** + * Callables registered via `FoundationDB::onNetworkThreadCompletion()`, + * invoked once from stopNetwork() after the FDB network thread has been + * joined. Deliberately NOT registered through the native + * `fdb_add_network_thread_completion_hook()` API: native completion + * hooks run on the FDB network thread, where executing PHP is unsafe + * (the Zend engine is not re-entrant). Running them on the main thread + * after pthread_join() preserves the ordering guarantee (the network + * thread — and therefore all native hooks — has already finished), so + * they are safe places to flush traces/metrics at shutdown. + * + * @var list + */ + private array $networkCompletionHooks = []; + private function __construct() { $this->fdb = FFI::cdef(self::FDB_HEADER, 'libfdb_c.so'); @@ -386,6 +404,34 @@ private function rollbackNetworkSetup(): void $this->networkThread = null; } + /** + * Register a callable to be invoked once when the FDB network thread + * stops, i.e. from stopNetwork() after the network thread has been + * joined. Useful for flushing traces/metrics at shutdown. + * + * NOTE: the callable is executed on the PHP main thread, not on the FDB + * network thread. The native `fdb_add_network_thread_completion_hook()` + * API is intentionally not used for PHP callables: its hook runs on the + * network thread, where executing PHP is unsafe. The deferred invocation + * in stopNetwork() happens strictly after the network thread (and any + * native hooks) has finished, so the ordering guarantee users rely on + * is preserved. + */ + public function onNetworkThreadCompletion(callable $hook): void + { + $this->networkCompletionHooks[] = $hook; + } + + /** + * @internal + * + * @return list + */ + public function getNetworkCompletionHooks(): array + { + return $this->networkCompletionHooks; + } + public function stopNetwork(): void { if (!$this->networkStarted) { @@ -413,6 +459,15 @@ public function stopNetwork(): void $this->networkStarted = false; $this->networkSetup = false; $this->networkThread = null; + + // Invoke registered completion hooks after the network thread has + // been joined and all native state has been torn down, so hooks can + // safely flush traces/metrics. Registered hooks are consumed. + $hooks = $this->networkCompletionHooks; + $this->networkCompletionHooks = []; + foreach ($hooks as $hook) { + $hook(); + } } public function isNetworkStarted(): bool diff --git a/tests/Integration/DatabaseMonitoringTest.php b/tests/Integration/DatabaseMonitoringTest.php index 4c93d1b..8fa4517 100644 --- a/tests/Integration/DatabaseMonitoringTest.php +++ b/tests/Integration/DatabaseMonitoringTest.php @@ -4,6 +4,7 @@ namespace CrazyGoat\FoundationDB\Tests\Integration; +use CrazyGoat\FoundationDB\FoundationDB; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; @@ -36,4 +37,20 @@ public function getClientStatusCanReturnParsedArray(): void self::assertNotEmpty($status); } + + #[Test] + public function getClientVersionReturnsNonEmptyString(): void + { + $version = FoundationDB::getClientVersion(); + + self::assertNotSame('', trim($version)); + } + + #[Test] + public function getServerProtocolReturnsPositiveProtocolVersion(): void + { + // FDB protocol versions are large positive integers (e.g. 0x00F08044 + // family for 7.0+), so a successful resolution is non-zero. + self::assertGreaterThan(0, $this->getDatabase()->getServerProtocol()); + } } diff --git a/tests/Integration/NetworkLifecycleTest.php b/tests/Integration/NetworkLifecycleTest.php index d6f45ef..a14b70b 100644 --- a/tests/Integration/NetworkLifecycleTest.php +++ b/tests/Integration/NetworkLifecycleTest.php @@ -177,4 +177,28 @@ public function networkCanBeStoppedAndRestarted(): void self::assertFalse($client->isNetworkStarted()); self::assertFalse($client->isNetworkSetup()); } + + /** + * Runs in a separate process for the same reason as above: stopping the + * network is only possible once per process. Verifies that completion + * hooks registered via `FoundationDB::onNetworkThreadCompletion()` are + * invoked exactly once, after the network has actually stopped. + */ + #[Test] + #[RunInSeparateProcess] + public function networkCompletionHooksRunOnStopNetwork(): void + { + $calls = []; + FoundationDB::onNetworkThreadCompletion(function () use (&$calls): void { + $calls[] = 'flush-traces'; + }); + + FoundationDB::open(); + self::assertSame([], $calls, 'Hooks must not run before the network stops'); + + $client = NativeClient::getInstance(); + $client->stopNetwork(); + + self::assertSame(['flush-traces'], $calls); + } } diff --git a/tests/Unit/FutureUInt64Test.php b/tests/Unit/FutureUInt64Test.php new file mode 100644 index 0000000..996604e --- /dev/null +++ b/tests/Unit/FutureUInt64Test.php @@ -0,0 +1,204 @@ +fdb_phpunit_stub_set_error(0); + } + + #[Test] + public function awaitReturnsTheValueResolvedByFdbFutureGetUint64(): void + { + $this->setStubValue(0); + self::assertSame(0, $this->makeFuture()->await()); + + $this->setStubValue(0x00F08044D0000001); + self::assertSame(0x00F08044D0000001, $this->makeFuture()->await()); + } + + #[Test] + public function awaitCachesTheResultAcrossCalls(): void + { + $this->setStubValue(4500); + $future = $this->makeFuture(); + + self::assertSame(4500, $future->await()); + + // Even if the stub value changed underneath, the cached result wins. + $this->setStubValue(1); + self::assertSame(4500, $future->await()); + } + + #[Test] + public function isErrorReportsFalseForASuccessfulFuture(): void + { + $this->setStubValue(0); + self::assertFalse($this->makeFuture()->isError()); + } + + #[Test] + public function isErrorIsCachedAndSurvivesMemoryRelease(): void + { + $this->setStubValue(0); + $future = $this->makeFuture(); + + self::assertSame(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 unsigned long long g_uint64_value = 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_uint64(void* f, unsigned long long* out) + { + (void)f; + *out = g_uint64_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_uint64(unsigned long long v) { g_uint64_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_uint64(FDBFuture* f, unsigned long long* out); + void fdb_phpunit_stub_set_error(int v); + void fdb_phpunit_stub_set_uint64(unsigned long long 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 setStubValue(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_uint64($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(): FutureUInt64 + { + $stub = self::$stub; + \assert($stub instanceof FFI); + + $nativeClient = (new \ReflectionClass(NativeClient::class))->newInstanceWithoutConstructor(); + $this->initializeReadOnly($nativeClient, 'fdb', $stub); + + $future = (new \ReflectionClass(FutureUInt64::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(); + $property = new \ReflectionProperty($declaringClass, $property); + $property->setValue($object, $value); + } +} diff --git a/tests/Unit/NativeClientCompletionHookTest.php b/tests/Unit/NativeClientCompletionHookTest.php new file mode 100644 index 0000000..b0791e4 --- /dev/null +++ b/tests/Unit/NativeClientCompletionHookTest.php @@ -0,0 +1,165 @@ +makeStartedClient(); + $calls = []; + $client->onNetworkThreadCompletion(function () use (&$calls): void { + $calls[] = 'first'; + }); + $client->onNetworkThreadCompletion(function () use (&$calls): void { + $calls[] = 'second'; + }); + + $client->stopNetwork(); + + self::assertSame(['first', 'second'], $calls); + } + + #[Test] + public function hooksAreConsumedAfterStopNetwork(): void + { + $client = $this->makeStartedClient(); + $calls = 0; + $client->onNetworkThreadCompletion(function () use (&$calls): void { + ++$calls; + }); + + $client->stopNetwork(); + // A second stop is a no-op (network no longer started). + $client->stopNetwork(); + + self::assertSame(1, $calls); + self::assertSame([], $client->getNetworkCompletionHooks()); + } + + #[Test] + public function stopNetworkWithoutHooksIsAPlainShutdown(): void + { + $client = $this->makeStartedClient(); + + $client->stopNetwork(); + + self::assertFalse($client->isNetworkStarted()); + } + + #[Test] + public function hooksRegisteredOnANotStartedNetworkAreKept(): void + { + $client = $this->makeStartedClient(); + $client->stopNetwork(); + $calls = 0; + $client->onNetworkThreadCompletion(function () use (&$calls): void { + ++$calls; + }); + + // Not started: stopNetwork() is a no-op and must not invoke hooks + // prematurely — they stay registered for the eventual shutdown. + $client->stopNetwork(); + + self::assertSame(0, $calls); + self::assertCount(1, $client->getNetworkCompletionHooks()); + } + + // -- Helpers ------------------------------------------------------------ + + /** + * A NativeClient in the "network started" state, with the FDB FFI handle + * pointed at a stub library that provides a no-op fdb_stop_network(). + */ + private function makeStartedClient(): NativeClient + { + if (!extension_loaded('ffi')) { + self::markTestSkipped('ext-ffi is not available'); + } + + $stub = $this->buildStub(); + + $client = (new \ReflectionClass(NativeClient::class))->newInstanceWithoutConstructor(); + $this->initializeReadOnly($client, 'fdb', $stub); + + $state = new \ReflectionClass(NativeClient::class); + $state->getProperty('networkStarted')->setValue($client, true); + $state->getProperty('networkThread')->setValue($client, null); + + return $client; + } + + private function buildStub(): FFI + { + $source = <<<'C' + int fdb_stop_network(void) { return 0; } + C; + + $header = <<<'C' + int fdb_stop_network(void); + C; + + $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 stub (%s): %s', + $command, + implode("\n", $outputLines), + )); + } + } + + try { + return FFI::cdef($header, $libraryPath); + } catch (\Throwable $e) { + self::markTestSkipped('Cannot load the FDB stub: ' . $e->getMessage()); + } + } + + private function initializeReadOnly(object $object, string $property, mixed $value): void + { + $declaringClass = (new \ReflectionProperty($object, $property))->getDeclaringClass()->getName(); + $property = new \ReflectionProperty($declaringClass, $property); + $property->setValue($object, $value); + } +}