From 60b56fbe713a2c8398c39fe32488f4960bf3f8f0 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Thu, 9 Jul 2026 07:46:56 -0400 Subject: [PATCH 1/4] Make the infix substr more slightly more realistic. --- tests/performance/Workload/Worker.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/performance/Workload/Worker.php b/tests/performance/Workload/Worker.php index 47fd09d5..fb6a0818 100644 --- a/tests/performance/Workload/Worker.php +++ b/tests/performance/Workload/Worker.php @@ -237,9 +237,9 @@ private function doSearchList(LdapClient $client): void private function doSearchSubstr(LdapClient $client): void { - $filter = $this->config->seedEntries > 0 - ? Filters::contains('mail', "seed-{$this->randomSeedIdx()}@") - : Filters::contains('mail', '@'); + $filter = $this->config->seedEntries >= 100 + ? Filters::contains('cn', (string) mt_rand(100, $this->config->seedEntries)) + : Filters::contains('cn', 'eed'); $client->search( Operations::search($filter) From 9743d6d0075159a1b94b3efffa401d68d3eb2e2c Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Thu, 9 Jul 2026 08:06:13 -0400 Subject: [PATCH 2/4] Add a way to re-index the trigram. --- .../SubstringIndexReindexer.php | 73 ++++++++++++++ .../SubstringIndexReindexerTest.php | 94 +++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SubstringIndex/SubstringIndexReindexer.php create mode 100644 tests/unit/Server/Backend/Storage/Adapter/SubstringIndex/SubstringIndexReindexerTest.php diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SubstringIndex/SubstringIndexReindexer.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SubstringIndex/SubstringIndexReindexer.php new file mode 100644 index 00000000..3d79350a --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SubstringIndex/SubstringIndexReindexer.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter\SubstringIndex; + +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Server\Backend\Storage\EntryStorageInterface; +use FreeDSx\Ldap\Server\Backend\Storage\StorageListOptions; + +/** + * Backfills the substring index by re-storing every entry; run it after enabling indexing on an existing directory or changing the indexed attributes. + * + * @api + * + * @author Chad Sikorra + */ +final readonly class SubstringIndexReindexer +{ + public function __construct( + private EntryStorageInterface $storage, + ) {} + + /** + * Re-store every entry in one transaction via raw storage: the substring index is rebuilt while operational attributes are preserved verbatim and no change is journaled. + */ + public function reindex(): void + { + $dns = $this->collectDns(); + + $this->storage->atomic(function () use ($dns): void { + foreach ($dns as $dn) { + $entry = $this->storage->find($dn); + if ($entry === null) { + continue; + } + + $this->storage->store($entry); + } + }); + } + + /** + * Drain the entry DNs up front so the entries table is not being read while it is re-stored. + * + * @return list + */ + private function collectDns(): array + { + $dns = []; + foreach ($this->storage->namingContexts() as $namingContext) { + $stream = $this->storage->list(StorageListOptions::matchAll( + $namingContext, + true, + )); + + foreach ($stream->entries as $entry) { + $dns[] = $entry->getDn(); + } + } + + return $dns; + } +} diff --git a/tests/unit/Server/Backend/Storage/Adapter/SubstringIndex/SubstringIndexReindexerTest.php b/tests/unit/Server/Backend/Storage/Adapter/SubstringIndex/SubstringIndexReindexerTest.php new file mode 100644 index 00000000..5b049acb --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Adapter/SubstringIndex/SubstringIndexReindexerTest.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Storage\Adapter\SubstringIndex; + +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\SqliteDialect; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\SharedPdoConnectionProvider; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\PdoStorage; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SubstringIndex\SubstringIndexReindexer; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SubstringIndex\TrigramSubstringIndex; +use PDO; +use PHPUnit\Framework\TestCase; + +final class SubstringIndexReindexerTest extends TestCase +{ + private PDO $pdo; + + private PdoStorage $storage; + + private Dn $dn; + + protected function setUp(): void + { + $this->pdo = new PDO('sqlite::memory:'); + $index = new TrigramSubstringIndex(); + PdoStorage::initialize( + $this->pdo, + new SqliteDialect(), + $index, + ); + + $this->storage = new PdoStorage( + new SharedPdoConnectionProvider( + $this->pdo, + fn(): PDO => $this->pdo, + ), + (new SqliteDialect())->createFilterTranslator($index), + new SqliteDialect(), + $index, + ); + + $this->dn = new Dn('cn=Smith,dc=example,dc=com'); + $this->storage->store(new Entry( + $this->dn, + new Attribute('cn', 'Smith'), + new Attribute('sn', 'Smith'), + new Attribute('entryUUID', '11111111-2222-3333-4444-555555555555'), + new Attribute('createTimestamp', '20260101000000Z'), + )); + } + + public function test_reindex_repopulates_the_substring_index(): void + { + // Simulate a directory that grew before substring indexing was enabled. + $this->pdo->exec('DELETE FROM entry_attribute_trigrams'); + + (new SubstringIndexReindexer($this->storage))->reindex(); + + $count = $this->pdo->query('SELECT COUNT(*) FROM entry_attribute_trigrams'); + self::assertNotFalse($count); + self::assertGreaterThan( + 0, + (int) $count->fetchColumn(), + ); + } + + public function test_reindex_preserves_entry_attributes(): void + { + $before = $this->storage->find($this->dn); + self::assertNotNull($before); + + (new SubstringIndexReindexer($this->storage))->reindex(); + + $after = $this->storage->find($this->dn); + self::assertNotNull($after); + self::assertEquals( + $before->toArray(), + $after->toArray(), + ); + } +} From 83da491c34aaca578d80d77e1dcce259182ee57c Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Thu, 9 Jul 2026 18:33:01 -0400 Subject: [PATCH 3/4] Add a FTS5 trigram implementation for SQLite when possible. --- .../Backend/Storage/Adapter/Pdo/PdoConfig.php | 4 + .../SubstringIndex/Fts5SubstringIndex.php | 179 ++++++++++++++++++ .../SubstringIndex/Fts5SubstringIndexTest.php | 126 ++++++++++++ 3 files changed, 309 insertions(+) create mode 100644 src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SubstringIndex/Fts5SubstringIndex.php create mode 100644 tests/unit/Server/Backend/Storage/Adapter/SubstringIndex/Fts5SubstringIndexTest.php diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/PdoConfig.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/PdoConfig.php index c8ed6699..6916bb53 100644 --- a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/PdoConfig.php +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/Pdo/PdoConfig.php @@ -16,6 +16,7 @@ use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\MysqlDialect; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\PdoDialectInterface; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\SqliteDialect; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SubstringIndex\Fts5SubstringIndex; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SubstringIndex\SubstringIndexInterface; use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SubstringIndex\TrigramSubstringIndex; use PDO; @@ -63,6 +64,9 @@ public static function forSqlite(string $path): self ], serializeSwooleWrites: true, driverExtension: 'pdo_sqlite', + substringIndex: Fts5SubstringIndex::isSupported() + ? new Fts5SubstringIndex() + : new TrigramSubstringIndex(), ); } diff --git a/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SubstringIndex/Fts5SubstringIndex.php b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SubstringIndex/Fts5SubstringIndex.php new file mode 100644 index 00000000..e0ab62d8 --- /dev/null +++ b/src/FreeDSx/Ldap/Server/Backend/Storage/Adapter/SubstringIndex/Fts5SubstringIndex.php @@ -0,0 +1,179 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FreeDSx\Ldap\Server\Backend\Storage\Adapter\SubstringIndex; + +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Schema\Text; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\PdoDialectInterface; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SqlFilter\SqlFilterResult; +use PDO; +use Throwable; + +/** + * Native SQLite substring index using an FTS5 trigram virtual table synced from the value sidecar by triggers. + * + * @author Chad Sikorra + */ +final class Fts5SubstringIndex implements SubstringIndexInterface +{ + /** + * @var list + */ + public const DEFAULT_ATTRIBUTES = [ + 'cn', + 'sn', + 'givenName', + 'displayName', + 'uid', + 'mail', + 'ou', + ]; + + private const MIN_TERM_LENGTH = 3; + + private const VTABLE_SQL = << Indexed attribute names, lowercased. + */ + private readonly array $attributes; + + /** + * @param list $attributes + */ + public function __construct(array $attributes = self::DEFAULT_ATTRIBUTES) + { + $set = []; + foreach ($attributes as $attribute) { + $set[strtolower($attribute)] = true; + } + + $this->attributes = $set; + } + + /** + * Whether this SQLite build has FTS5 with the trigram tokenizer (compile-time, so a throwaway probe suffices). + */ + public static function isSupported(): bool + { + if (self::$supported !== null) { + return self::$supported; + } + + if (!extension_loaded('pdo_sqlite')) { + return self::$supported = false; + } + + try { + $pdo = new PDO('sqlite::memory:'); + $pdo->exec("CREATE VIRTUAL TABLE probe USING fts5(x, tokenize = 'trigram')"); + + return self::$supported = true; + } catch (Throwable) { + return self::$supported = false; + } + } + + public function schemaStatements(PdoDialectInterface $dialect): array + { + if ($this->attributes === []) { + return []; + } + + $scope = implode( + ', ', + array_map( + static fn(string $attribute): string => "'" . str_replace("'", "''", $attribute) . "'", + array_keys($this->attributes), + ), + ); + + return [ + self::VTABLE_SQL, + sprintf(self::INSERT_TRIGGER_SQL, $scope), + sprintf(self::DELETE_TRIGGER_SQL, $scope), + ]; + } + + public function maintain( + string $lcDn, + Entry $entry, + callable $execute, + ): void { + // The sync triggers index/de-index off the sidecar rows PdoStorage already writes; nothing to do per entry. + } + + public function buildSubstringPredicate( + string $attributeLower, + array $fragments, + ): ?SqlFilterResult { + if (!isset($this->attributes[$attributeLower])) { + return null; + } + + $terms = []; + foreach ($fragments as $fragment) { + if (!Text::isUtf8($fragment) || mb_strlen($fragment, 'UTF-8') < self::MIN_TERM_LENGTH) { + continue; + } + + $terms[] = '"' . str_replace('"', '""', $fragment) . '"'; + } + + if ($terms === []) { + return null; + } + + return new SqlFilterResult( + self::MATCH_SQL, + [$attributeLower, implode(' AND ', $terms)], + isExact: false, + referencedAttributes: [$attributeLower], + ); + } +} diff --git a/tests/unit/Server/Backend/Storage/Adapter/SubstringIndex/Fts5SubstringIndexTest.php b/tests/unit/Server/Backend/Storage/Adapter/SubstringIndex/Fts5SubstringIndexTest.php new file mode 100644 index 00000000..5d50a60c --- /dev/null +++ b/tests/unit/Server/Backend/Storage/Adapter/SubstringIndex/Fts5SubstringIndexTest.php @@ -0,0 +1,126 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Ldap\Server\Backend\Storage\Adapter\SubstringIndex; + +use FreeDSx\Ldap\Entry\Attribute; +use FreeDSx\Ldap\Entry\Dn; +use FreeDSx\Ldap\Entry\Entry; +use FreeDSx\Ldap\Search\Filters; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Dialect\SqliteDialect; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\Pdo\SharedPdoConnectionProvider; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\PdoStorage; +use FreeDSx\Ldap\Server\Backend\Storage\Adapter\SubstringIndex\Fts5SubstringIndex; +use FreeDSx\Ldap\Server\Backend\Storage\StorageListOptions; +use PDO; +use PHPUnit\Framework\TestCase; + +final class Fts5SubstringIndexTest extends TestCase +{ + public function test_build_substring_predicate_declines_an_unindexed_attribute(): void + { + self::assertNull( + (new Fts5SubstringIndex(['cn']))->buildSubstringPredicate('mail', ['smith']), + ); + } + + public function test_build_substring_predicate_declines_fragments_shorter_than_three_chars(): void + { + self::assertNull( + (new Fts5SubstringIndex(['cn']))->buildSubstringPredicate('cn', ['ab']), + ); + } + + public function test_build_substring_predicate_emits_an_fts_match(): void + { + $result = (new Fts5SubstringIndex(['cn']))->buildSubstringPredicate('cn', ['smith', 'jones']); + + self::assertNotNull($result); + self::assertStringContainsString( + 'entry_attribute_fts MATCH ?', + $result->sql, + ); + self::assertFalse($result->isExact); + self::assertSame( + ['cn', '"smith" AND "jones"'], + $result->params, + ); + } + + public function test_schema_statements_scope_the_triggers_to_the_indexed_attributes(): void + { + $statements = implode( + "\n", + (new Fts5SubstringIndex(['cn', 'sn']))->schemaStatements(new SqliteDialect()), + ); + + self::assertStringContainsString( + "USING fts5(", + $statements, + ); + self::assertStringContainsString( + "attr_name_lower IN ('cn', 'sn')", + $statements, + ); + } + + public function test_infix_search_matches_substrings_precisely(): void + { + if (!Fts5SubstringIndex::isSupported()) { + self::markTestSkipped('This SQLite build lacks the FTS5 trigram tokenizer.'); + } + + $pdo = new PDO('sqlite::memory:'); + $index = new Fts5SubstringIndex(); + PdoStorage::initialize( + $pdo, + new SqliteDialect(), + $index, + ); + + $storage = new PdoStorage( + new SharedPdoConnectionProvider( + $pdo, + fn(): PDO => $pdo, + ), + (new SqliteDialect())->createFilterTranslator($index), + new SqliteDialect(), + $index, + ); + + $storage->store(new Entry( + new Dn('cn=blacksmith,dc=example,dc=com'), + new Attribute('cn', 'blacksmith'), + )); + $storage->store(new Entry( + new Dn('cn=scatter,dc=example,dc=com'), + new Attribute('cn', 'smi mit ith'), + )); + + $stream = $storage->list(new StorageListOptions( + baseDn: new Dn('dc=example,dc=com'), + subtree: true, + filter: Filters::contains('cn', 'smith'), + )); + + $dns = []; + foreach ($stream->entries as $entry) { + $dns[] = $entry->getDn()->toString(); + } + + self::assertSame( + ['cn=blacksmith,dc=example,dc=com'], + $dns, + ); + } +} From 8cc034507fbd6c5423d5ef407073705b3913e7b1 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Thu, 9 Jul 2026 22:13:29 -0400 Subject: [PATCH 4/4] Make the load test use forked processes instead of coroutines. --- composer.json | 1 + tests/bin/internals/bench_bootstrap.php | 8 + tests/bin/ldap-load-test.php | 4 +- tests/performance/Driver.php | 390 ++++++++++++++++------ tests/performance/Stats/StatsSnapshot.php | 47 +++ tests/performance/Workload/Worker.php | 72 ++-- tests/profile/load-test.sh | 15 + 7 files changed, 398 insertions(+), 139 deletions(-) create mode 100755 tests/profile/load-test.sh diff --git a/composer.json b/composer.json index 4d938d77..db4840a7 100644 --- a/composer.json +++ b/composer.json @@ -81,6 +81,7 @@ "LDAP_TESTS_ENABLED=1 php -d xdebug.mode=off vendor/bin/phpunit --testsuite integration" ], "test-load": "@php -d xdebug.mode=off -d opcache.enable_cli=1 -d opcache.jit=off tests/bin/ldap-load-test.php --backend=sqlite --runner=pcntl --duration=10 --warmup=2 --clients=8 --output=text", + "test-load-docker": "tests/profile/load-test.sh", "test-load-compare": "@php -d xdebug.mode=off -d opcache.enable_cli=1 -d opcache.jit_buffer_size=128M -d opcache.jit=tracing tests/bin/ldap-bench-compare.php", "profile": "tests/profile/profile.sh", "profile-up": "docker compose -f tests/profile/docker-compose.yml up -d --build --wait", diff --git a/tests/bin/internals/bench_bootstrap.php b/tests/bin/internals/bench_bootstrap.php index 8ba23704..f82e95cc 100644 --- a/tests/bin/internals/bench_bootstrap.php +++ b/tests/bin/internals/bench_bootstrap.php @@ -32,6 +32,14 @@ return; } +/** + * The load-test driver forks one client process per connection. Tracing JIT corrupts forked + * workers (SIGILL under load), so the driver runs interpreted, mirroring the pcntl server default. + */ +if (basename($argv[0] ?? '') === 'ldap-load-test.php') { + return; +} + /** * In multi-driver mode the parent does no hot work — it only orchestrates child * workers (each spawned with JIT flags). Skipping the JIT re-exec here avoids a diff --git a/tests/bin/ldap-load-test.php b/tests/bin/ldap-load-test.php index 2a87715e..f9a06d90 100644 --- a/tests/bin/ldap-load-test.php +++ b/tests/bin/ldap-load-test.php @@ -6,8 +6,8 @@ * Load-test driver for the FreeDSx LDAP storage backends. * * Spawns an LDAP server with the chosen backend+runner, fires concurrent LDAP operations from a - * Swoole coroutine pool, then prints per-op latency/throughput/error stats. Run with --help to - * see the full option list. + * forked client pool (one process per client), then prints per-op latency/throughput/error stats. + * Run with --help to see the full option list. */ use Symfony\Component\Console\Application; diff --git a/tests/performance/Driver.php b/tests/performance/Driver.php index 67311290..f2725ab5 100644 --- a/tests/performance/Driver.php +++ b/tests/performance/Driver.php @@ -14,10 +14,6 @@ namespace Tests\Performance\FreeDSx\Ldap; use RuntimeException; -use Swoole\Coroutine; -use Swoole\Coroutine\Channel; -use Swoole\Coroutine\WaitGroup; -use Swoole\Runtime; use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\OutputInterface; use Tests\Performance\FreeDSx\Ldap\Server\ServerManager; @@ -28,8 +24,8 @@ use Throwable; /** - * Orchestrates a load-test run end-to-end: server lifecycle, coroutine pool, warmup timer, - * barrier + deadline coordination, then a rendered report. + * Orchestrates a load-test run end-to-end: server lifecycle, forked client pool, barrier + deadline + * coordination, cross-process stats merge, then a rendered report. */ final class Driver { @@ -44,15 +40,10 @@ public function run( OutputInterface $output, ?callable $afterRun = null, ): StatsSnapshot { - $this->assertSwooleAvailable(); - - if ($this->config->rngSeed !== null) { - mt_srand($this->config->rngSeed); - } + $this->assertPcntlAvailable(); $progress = $this->progressChannel($output); $mix = new WorkloadMix($this->config->mix); - $stats = new StatsCollector(); $serverManager = $this->config->serverMode === 'spawn' ? new ServerManager($this->config) : null; @@ -72,10 +63,7 @@ public function run( $progress->writeln($this->describeRunStart()); try { - $snapshot = $this->runCoroutinePool( - $mix, - $stats, - ); + $snapshot = $this->runForkPool($mix); // While the server is still up, let the caller read cn=monitor for an apples-to-apples cross-check. if ($afterRun !== null) { $afterRun(); @@ -118,94 +106,190 @@ private function describeRunStart(): string : sprintf('ops/client=%d', $this->config->ops); return sprintf( - 'Running load test: clients=%d, warmup=%ds, %s...', + 'Running load test: clients=%d (forked), warmup=%ds, %s...', $this->config->clients, $this->config->warmup, $budget, ); } - private function runCoroutinePool(WorkloadMix $mix, StatsCollector $stats): StatsSnapshot + /** + * Fork one process per client, barrier on readiness, broadcast the schedule, then merge their snapshots. + */ + private function runForkPool(WorkloadMix $mix): StatsSnapshot { - Runtime::enableCoroutine(SWOOLE_HOOK_ALL); - - $elapsedSeconds = 0.0; - $workerErrors = []; - - Coroutine\run(function () use ($mix, $stats, &$elapsedSeconds, &$workerErrors): void { - $clients = $this->config->clients; - - /** @var Channel $readyBarrier */ - $readyBarrier = new Channel($clients); - - /** @var Channel $startSignal */ - $startSignal = new Channel($clients); - $waitGroup = new WaitGroup(); - - for ($i = 0; $i < $clients; $i++) { - $waitGroup->add(); - $workerId = $i; - Coroutine::create(function () use ($workerId, $mix, $stats, $readyBarrier, $startSignal, $waitGroup, &$workerErrors): void { - try { - (new Worker( - workerId: $workerId, - config: $this->config, - mix: $mix, - stats: $stats, - readyBarrier: $readyBarrier, - startSignal: $startSignal, - opsCap: $this->config->ops, - ))->run(); - } catch (Throwable $e) { - $workerErrors[] = $e; - } finally { - $waitGroup->done(); - } - }); - } + pcntl_signal(SIGPIPE, SIG_IGN); - $allReady = $this->awaitReady($readyBarrier, $clients); + $children = $this->forkChildren($mix); + $allReady = $this->awaitReady($children); - $deadline = $this->config->duration !== null - ? microtime(true) + $this->config->warmup + (float) $this->config->duration - : null; + $goTime = microtime(true); + $recordStart = $goTime + $this->config->warmup; + $deadline = $this->config->duration !== null + ? $recordStart + (float) $this->config->duration + : null; - for ($i = 0; $i < $clients; $i++) { - $startSignal->push($allReady ? $deadline : false); - } + $this->broadcastGo( + $children, + $allReady, + $recordStart, + $deadline, + ); - if ($allReady) { - $elapsedSeconds = $this->driveWarmupAndRecord($stats, $waitGroup); - } else { - $waitGroup->wait(); - } - }); + $failures = $this->awaitChildren($children); + $elapsed = microtime(true) - $recordStart; - if ($workerErrors !== []) { - $first = $workerErrors[0]; + if (!$allReady) { throw new RuntimeException(sprintf( - '%d of %d workers failed; first error: %s: %s', - count($workerErrors), + 'One or more of the %d load-test workers failed to connect to the server.', $this->config->clients, - $first::class, - $first->getMessage(), - ), 0, $first); + )); } - return $stats->snapshot($elapsedSeconds); + if ($failures !== []) { + throw new RuntimeException(sprintf( + '%d of %d load-test workers terminated abnormally: %s', + count($failures), + $this->config->clients, + implode('; ', $failures), + )); + } + + return StatsSnapshot::merge( + $this->collectSnapshots($children), + $elapsed, + ); } /** - * @param Channel $readyBarrier + * @return list */ - private function awaitReady(Channel $readyBarrier, int $expected): bool + private function forkChildren(WorkloadMix $mix): array { - $allReady = true; + $children = []; + + for ($i = 0; $i < $this->config->clients; $i++) { + [$parentReady, $childReady] = $this->socketPair(); + [$parentGo, $childGo] = $this->socketPair(); + $resultPath = sprintf( + '%s/freedsx_loadtest_%d_%d.dat', + sys_get_temp_dir(), + getmypid(), + $i, + ); + + $pid = pcntl_fork(); + if ($pid === -1) { + throw new RuntimeException('Failed to fork a load-test worker process.'); + } + + if ($pid === 0) { + fclose($parentReady); + fclose($parentGo); + $this->runChild( + $i, + $mix, + $childReady, + $childGo, + $resultPath, + ); + + exit(0); + } + + fclose($childReady); + fclose($childGo); + $children[] = [ + 'pid' => $pid, + 'ready' => $parentReady, + 'go' => $parentGo, + 'result' => $resultPath, + 'id' => $i, + ]; + } + + return $children; + } + + /** + * @param resource $readySocket + * @param resource $goSocket + */ + private function runChild( + int $workerId, + WorkloadMix $mix, + $readySocket, + $goSocket, + string $resultPath, + ): void { + $this->seedChildRng($workerId); + + $stats = new StatsCollector(); + $worker = new Worker( + workerId: $workerId, + config: $this->config, + mix: $mix, + stats: $stats, + opsCap: $this->config->ops, + ); + + try { + $client = $worker->connect(); + } catch (Throwable) { + fwrite($readySocket, '0'); + fgets($goSocket); // wait for the broadcast so the parent's write never SIGPIPEs + fclose($goSocket); + + return; + } + + fwrite($readySocket, '1'); + $signal = $this->awaitGo($goSocket); + + if (!$signal['proceed']) { + $worker->disconnect($client); + + return; + } + + try { + $worker->run( + $client, + $signal['recordStart'], + $signal['deadline'], + ); + } finally { + $stats->stopRecording(); + $worker->disconnect($client); + } + + file_put_contents( + $resultPath, + serialize($stats->snapshot(0.0)), + ); + } + + private function seedChildRng(int $workerId): void + { + if ($this->config->rngSeed !== null) { + mt_srand($this->config->rngSeed + $workerId); + + return; + } + + // Forked children inherit an identical mt_rand state; reseed each distinctly by its own PID. + mt_srand(((int) getmypid()) * 7919 + $workerId); + } - for ($i = 0; $i < $expected; $i++) { - $ready = $readyBarrier->pop(); + /** + * @param list $children + */ + private function awaitReady(array $children): bool + { + $allReady = true; - if ($ready !== true) { + foreach ($children as $child) { + if (fread($child['ready'], 1) !== '1') { $allReady = false; } } @@ -213,37 +297,147 @@ private function awaitReady(Channel $readyBarrier, int $expected): bool return $allReady; } - private function driveWarmupAndRecord(StatsCollector $stats, WaitGroup $waitGroup): float + /** + * @param list $children + */ + private function broadcastGo( + array $children, + bool $proceed, + float $recordStart, + ?float $deadline, + ): void { + $message = sprintf( + "%d %.6f %s\n", + $proceed ? 1 : 0, + $recordStart, + $deadline !== null ? sprintf('%.6f', $deadline) : '-', + ); + + foreach ($children as $child) { + fwrite($child['go'], $message); + fclose($child['go']); + } + } + + /** + * @param resource $goSocket + * + * @return array{proceed: bool, recordStart: float, deadline: float|null} + */ + private function awaitGo($goSocket): array + { + $line = fgets($goSocket); + fclose($goSocket); + + $parts = is_string($line) + ? explode(' ', trim($line)) + : []; + + if (count($parts) < 3) { + return ['proceed' => false, 'recordStart' => 0.0, 'deadline' => null]; + } + + return [ + 'proceed' => $parts[0] === '1', + 'recordStart' => (float) $parts[1], + 'deadline' => $parts[2] === '-' ? null : (float) $parts[2], + ]; + } + + /** + * @param list $children + * + * @return list descriptions of any workers that crashed or exited non-zero + */ + private function awaitChildren(array $children): array { - $recordingStart = 0.0; + $failures = []; + + foreach ($children as $child) { + $status = 0; + pcntl_waitpid($child['pid'], $status); + + if (!is_int($status)) { + continue; + } + + if (pcntl_wifsignaled($status)) { + $failures[] = sprintf( + 'worker %d killed by signal %d', + $child['id'], + pcntl_wtermsig($status), + ); + + continue; + } - Coroutine::create(function () use ($stats, &$recordingStart): void { - if ($this->config->warmup > 0) { - Coroutine::sleep((float) $this->config->warmup); + $exit = pcntl_wexitstatus($status); + if ($exit !== 0) { + $failures[] = sprintf( + 'worker %d exited with status %d', + $child['id'], + $exit, + ); } + } - $recordingStart = microtime(true); - $stats->startRecording(); - }); + return $failures; + } - $waitGroup->wait(); + /** + * @param list $children + * + * @return list + */ + private function collectSnapshots(array $children): array + { + $snapshots = []; - $stats->stopRecording(); + foreach ($children as $child) { + if (!is_file($child['result'])) { + continue; + } + + $raw = file_get_contents($child['result']); + @unlink($child['result']); + $snapshot = is_string($raw) + ? unserialize($raw, ['allowed_classes' => [StatsSnapshot::class]]) + : false; + + if ($snapshot instanceof StatsSnapshot) { + $snapshots[] = $snapshot; + } + } + + return $snapshots; + } + + /** + * @return array + */ + private function socketPair(): array + { + $pair = stream_socket_pair( + STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + 0, + ); + + if ($pair === false) { + throw new RuntimeException('Failed to create a socket pair for worker coordination.'); + } - return $recordingStart > 0.0 - ? microtime(true) - $recordingStart - : 0.0; + return $pair; } - private function assertSwooleAvailable(): void + private function assertPcntlAvailable(): void { - if (extension_loaded('swoole')) { + if (function_exists('pcntl_fork') && function_exists('pcntl_waitpid')) { return; } throw new RuntimeException( - 'The load-test driver requires ext-swoole for concurrent coroutine-based clients. ' - . 'Install via PECL: pecl install swoole (^5.1 for PHP 8.3/8.4, ^6.0 for PHP 8.5+).', + 'The load-test driver requires ext-pcntl to fork one process per concurrent client.', ); } } diff --git a/tests/performance/Stats/StatsSnapshot.php b/tests/performance/Stats/StatsSnapshot.php index e13e05fa..76822279 100644 --- a/tests/performance/Stats/StatsSnapshot.php +++ b/tests/performance/Stats/StatsSnapshot.php @@ -34,6 +34,53 @@ public function __construct( public readonly float $elapsedSeconds, ) {} + /** + * Combine per-worker snapshots (from forked processes) into one, concatenating samples and summing counters. + * + * @param list $snapshots + */ + public static function merge( + array $snapshots, + float $elapsedSeconds, + ): self { + $samples = []; + $counts = []; + $errors = []; + $errorClasses = []; + $substituted = []; + + foreach ($snapshots as $snapshot) { + foreach ($snapshot->samples as $op => $opSamples) { + foreach ($opSamples as $nanos) { + $samples[$op][] = $nanos; + } + } + foreach ($snapshot->counts as $op => $count) { + $counts[$op] = ($counts[$op] ?? 0) + $count; + } + foreach ($snapshot->errors as $op => $count) { + $errors[$op] = ($errors[$op] ?? 0) + $count; + } + foreach ($snapshot->errorClasses as $op => $classes) { + foreach ($classes as $class => $count) { + $errorClasses[$op][$class] = ($errorClasses[$op][$class] ?? 0) + $count; + } + } + foreach ($snapshot->substituted as $key => $count) { + $substituted[$key] = ($substituted[$key] ?? 0) + $count; + } + } + + return new self( + samples: $samples, + counts: $counts, + errors: $errors, + errorClasses: $errorClasses, + substituted: $substituted, + elapsedSeconds: max($elapsedSeconds, 0.000001), + ); + } + /** * @return list ops ordered by total activity (successes + errors) descending */ diff --git a/tests/performance/Workload/Worker.php b/tests/performance/Workload/Worker.php index fb6a0818..ab7fda7e 100644 --- a/tests/performance/Workload/Worker.php +++ b/tests/performance/Workload/Worker.php @@ -24,13 +24,12 @@ use FreeDSx\Ldap\Search\Filter\FilterInterface; use FreeDSx\Ldap\Search\Filters; use LogicException; -use Swoole\Coroutine\Channel; use Tests\Performance\FreeDSx\Ldap\Config; use Tests\Performance\FreeDSx\Ldap\Stats\StatsCollector; use Throwable; /** - * Generates load in a single Swoole coroutine. + * Generates load from a single forked client process against the server. */ final class Worker { @@ -50,17 +49,11 @@ final class Worker private int $addSeq = 0; - /** - * @param Channel $readyBarrier - * @param Channel $startSignal - */ public function __construct( private readonly int $workerId, private readonly Config $config, private readonly WorkloadMix $mix, private readonly StatsCollector $stats, - private readonly Channel $readyBarrier, - private readonly Channel $startSignal, private readonly ?int $opsCap, ) { $this->compareDn = 'cn=alice,' . $this->config->writeBase; @@ -73,39 +66,48 @@ public function __construct( ]; } - public function run(): void + /** + * Open the connection and bind; throws on failure so the parent can fail the readiness barrier. + */ + public function connect(): LdapClient { $client = $this->buildClient(); + $client->bind( + $this->config->bindDn, + $this->config->bindPassword, + ); - try { - $client->bind( - $this->config->bindDn, - $this->config->bindPassword, - ); - } catch (Throwable $e) { - $this->readyBarrier->push(false); - - throw $e; - } - - $this->readyBarrier->push(true); - - $signal = $this->startSignal->pop(); - if ($signal === false) { - $this->cleanup($client); - - return; - } - - $deadline = is_float($signal) ? $signal : null; + return $client; + } + /** + * Drive the workload until the deadline (or ops cap), enabling recording once past the warmup mark. + */ + public function run( + LdapClient $client, + float $recordStart, + ?float $deadline, + ): void { + $recording = false; $iterations = 0; + while ($this->shouldContinue($deadline, $iterations)) { + if (!$recording && microtime(true) >= $recordStart) { + $this->stats->startRecording(); + $recording = true; + } + $this->runOne($client); $iterations++; } + } - $this->cleanup($client); + public function disconnect(LdapClient $client): void + { + try { + $client->unbind(); + } catch (Throwable) { + } } private function deriveMailDomain(string $baseDn): string @@ -352,14 +354,6 @@ private function doDelete(LdapClient $client): void array_splice($this->ownedDns, $idx, 1); } - private function cleanup(LdapClient $client): void - { - try { - $client->unbind(); - } catch (Throwable) { - } - } - private function buildClient(): LdapClient { return new LdapClient( diff --git a/tests/profile/load-test.sh b/tests/profile/load-test.sh new file mode 100755 index 00000000..acaa510d --- /dev/null +++ b/tests/profile/load-test.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# +# Run the load test inside the Linux profiling container. +# +# Usage (from the repo root): +# composer test-load-docker +# composer test-load-docker -- --seed-entries=5000 --clients=4 +# +set -euo pipefail + +compose_file="$(cd "$(dirname "$0")" && pwd)/docker-compose.yml" + +docker compose -f "$compose_file" up -d --build --wait +docker exec freedsx-profile composer install --no-interaction --quiet +docker exec freedsx-profile composer test-load -- "$@"