From feac22c62c3cd3c5f016ee68596b6a1880054dc4 Mon Sep 17 00:00:00 2001 From: Headgent Development Date: Mon, 10 Aug 2026 10:16:33 +0200 Subject: [PATCH] feat(bootstrap): ENV-driven ConnectionPoolConfig for the connection pool Five db_pool_* keys (validate_connections, health_check_cache_ttl, health_check_negative_cache_ttl, load_balancing_strategy, sticky_writer) build an optional ConnectionPoolConfig; without any key set the pool call stays two-argument and behavior is byte-identical. Requires jardisadapter/dbconnection >=1.1.0 for db_pool_sticky_writer (documented fallback to plain PDO via existing Throwable path otherwise). --- README.md | 14 ++ composer.json | 2 +- docs/env-examples/.env.database.example | 16 ++ docs/env-examples/README.md | 40 +++-- .../Handler/BuildConnectionFromEnv.php | 12 +- .../BuildConnectionPoolConfigFromEnv.php | 75 ++++++++ .../Handler/BuildConnectionFromEnvTest.php | 29 +++ .../BuildConnectionPoolConfigFromEnvTest.php | 168 ++++++++++++++++++ 8 files changed, 339 insertions(+), 17 deletions(-) create mode 100644 src/Bootstrap/Handler/BuildConnectionPoolConfigFromEnv.php create mode 100644 tests/Unit/Bootstrap/Handler/BuildConnectionPoolConfigFromEnvTest.php diff --git a/README.md b/README.md index 47988dc..97f4996 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,20 @@ $kernel = new DomainKernel( load balancing, and automatic writer fallback when no readers are available. Everything downstream (`$kernel->dbConnection()`) doesn't change. +When `BuildDomainKernelFromEnv` builds the pool, five optional `DB_POOL_*` +keys tune its `ConnectionPoolConfig` (see +[`docs/env-examples/.env.database.example`](docs/env-examples/.env.database.example)): +`DB_POOL_VALIDATE_CONNECTIONS`, `DB_POOL_HEALTH_CHECK_CACHE_TTL`, +`DB_POOL_HEALTH_CHECK_NEGATIVE_CACHE_TTL`, `DB_POOL_LOAD_BALANCING_STRATEGY` +(`round-robin` | `random`) and `DB_POOL_STICKY_WRITER` +(`stickyWriterDuringTransaction`: reads inside an open writer transaction go +to the writer). Setting none of them keeps the adapter defaults — the pool is +built exactly as before; setting any builds an explicit config where only the +set keys deviate. `DB_POOL_STICKY_WRITER` requires +`jardisadapter/dbconnection` >= 1.1.0 — with an older version installed the +pool build fails and the existing fallback applies: a plain `PDO` on +`DB_HOST` plus an `error_log` notice. + --- ## Architecture diff --git a/composer.json b/composer.json index b2a3144..592458b 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,7 @@ "squizlabs/php_codesniffer": "3.13.6", "jardis/dev-skills": "^1.0", "jardisadapter/cache": "^1.0", - "jardisadapter/dbconnection": "^1.0", + "jardisadapter/dbconnection": "^1.1", "jardisadapter/eventdispatcher": "^1.0", "jardisadapter/filesystem": "^1.0", "jardisadapter/http": "^1.0", diff --git a/docs/env-examples/.env.database.example b/docs/env-examples/.env.database.example index f3b2095..372111d 100644 --- a/docs/env-examples/.env.database.example +++ b/docs/env-examples/.env.database.example @@ -23,6 +23,22 @@ DB_CHARSET=utf8mb4 #DB_READER1_DATABASE=app #DB_READER2_HOST=replica2.internal +# ----------------------------------------------------------------------------- +# Pool tuning (optional, only read when a ConnectionPool is built — i.e. at +# least one DB_READER{N}_HOST plus jardisadapter/dbconnection installed). +# Setting NONE of these keeps the adapter's ConnectionPoolConfig defaults; +# setting ANY builds an explicit config where only the set keys deviate. +# DB_POOL_STICKY_WRITER requires jardisadapter/dbconnection >= 1.1.0 — on +# older versions the pool build fails and the existing fallback to a plain +# PDO on DB_HOST applies (with an error_log notice). +# Booleans: only the literal `true` is true. +# ----------------------------------------------------------------------------- +#DB_POOL_VALIDATE_CONNECTIONS=true +#DB_POOL_HEALTH_CHECK_CACHE_TTL=30 +#DB_POOL_HEALTH_CHECK_NEGATIVE_CACHE_TTL=0 +#DB_POOL_LOAD_BALANCING_STRATEGY=round-robin +#DB_POOL_STICKY_WRITER=false + # ----------------------------------------------------------------------------- # SQLite (overrides everything above) # ----------------------------------------------------------------------------- diff --git a/docs/env-examples/README.md b/docs/env-examples/README.md index 9649847..1d3e008 100644 --- a/docs/env-examples/README.md +++ b/docs/env-examples/README.md @@ -13,27 +13,29 @@ Content revised from the Builder's former `SharedRuntime/` ENV generat (`tools/builder tests/Builder/Generated/Domain/SharedRuntime`, read-only source for this revision) — that generat used stale, unread ENV keys (`DB_WRITER_*`, `CACHE_REDIS_ENABLED`, …, and a `Messaging` section this -kernel does not build). The keys below are the ones the ten -`Bootstrap/Handler/*` closures actually read — **ten closures, not eleven**: -`ls src/Bootstrap/Handler/*.php` → 10 files. Eleven is the packed -`DomainKernel`'s **accessor** count instead (`grep -cE "public function -[a-z]" src/DomainKernel.php` → 11) — a different layer (the Koffer's read -side) from the Bootstrap-Packer's build side documented here. Don't conflate -the two counts. +kernel does not build). The keys below are the ones the eleven +`Bootstrap/Handler/*` closures actually read: +`ls src/Bootstrap/Handler/*.php` → 11 files (ten before +`BuildConnectionPoolConfigFromEnv` joined for the `DB_POOL_*` keys). That +this now numerically matches the packed `DomainKernel`'s **accessor** count +(`grep -cE "public function [a-z]" src/DomainKernel.php` → 11) is +coincidence — a different layer (the Koffer's read side) from the +Bootstrap-Packer's build side documented here. Don't conflate the two +counts. ## Files | Template | Feeds | Required to activate | |---|---|---| | `.env.example` | cascade root — `load()`s the others | copy first, adjust `load?()` list to what you use | -| `.env.database.example` | `BuildConnectionFromEnv` | `DB_HOST` (or `DB_DRIVER=sqlite`) | +| `.env.database.example` | `BuildConnectionFromEnv` (incl. the `DB_POOL_*` keys its `BuildConnectionPoolConfigFromEnv` sub-closure reads) | `DB_HOST` (or `DB_DRIVER=sqlite`) | | `.env.redis.example` | `BuildRedisFromEnv` — shared by cache + logger (fan-out, D4) | `REDIS_HOST` | | `.env.cache.example` | `BuildCacheFromEnv` (requires `jardisadapter/cache`) | `CACHE_LAYERS` | | `.env.logger.example` | `BuildLoggerFromEnv` (requires `jardisadapter/logger`) | `LOG_HANDLERS` | | `.env.http.example` | `BuildHttpClientFromEnv` (requires `jardisadapter/http`) | none — works with zero config once the adapter is installed | | `.env.mail.example` | `BuildMailerFromEnv` (requires `jardisadapter/mailer`) | `MAIL_HOST` | -Four of the ten Bootstrap handlers need no ENV at all and have no template +Four of the eleven Bootstrap handlers need no ENV at all and have no template here: `BuildFilesystemFromEnv` (stateless factory) and `BuildEventListenerProviderFromEnv` / `BuildEventDispatcherFromProvider` (pure in-memory pair, D3) activate purely by the corresponding adapter package @@ -68,12 +70,22 @@ the doc list AC A6 diffs against the source — see "Verifying this list" below. | `DB_READER{N}_USER` | writer's `DB_USER` | falls back to the writer value if unset | | `DB_READER{N}_PASSWORD` | writer's `DB_PASSWORD` | falls back to the writer value if unset | | `DB_READER{N}_DATABASE` | writer's `DB_DATABASE` | falls back to the writer value if unset | +| `DB_POOL_VALIDATE_CONNECTIONS` | adapter default (`true`) | pool only; string comparison — only the literal `true` is true | +| `DB_POOL_HEALTH_CHECK_CACHE_TTL` | adapter default (`30`) | pool only; seconds | +| `DB_POOL_HEALTH_CHECK_NEGATIVE_CACHE_TTL` | adapter default (`0`) | pool only; seconds, `0` = no negative caching | +| `DB_POOL_LOAD_BALANCING_STRATEGY` | adapter default (`round-robin`) | pool only; `round-robin` \| `random` | +| `DB_POOL_STICKY_WRITER` | adapter default (`false`) | pool only; `stickyWriterDuringTransaction` — **requires `jardisadapter/dbconnection` >= 1.1.0**; on older versions the pool build fails into the plain-PDO fallback (error_log notice) | At least one `DB_READER{N}_HOST` plus `jardisadapter/dbconnection` installed (`class_exists(ConnectionPool::class)`) builds a `ConnectionPool`; otherwise a -plain `PDO` on `DB_HOST` is returned. Code: -`src/Bootstrap/Handler/BuildConnectionFromEnv.php:35-160` (reader family -specifically `:135-160`, `DB_PATH` specifically `:57`). +plain `PDO` on `DB_HOST` is returned. The five `DB_POOL_*` keys are read by +`BuildConnectionPoolConfigFromEnv` and only take effect on the pool branch: +setting none of them keeps the two-argument `ConnectionPool` construction +(adapter `ConnectionPoolConfig` defaults, behaviour unchanged); setting any +builds an explicit config in which only the set keys deviate from those +defaults. Code: `src/Bootstrap/Handler/BuildConnectionFromEnv.php` (reader +family in `findReaders`, `DB_PATH` in `buildSqlite`) and +`src/Bootstrap/Handler/BuildConnectionPoolConfigFromEnv.php`. ### `BuildRedisFromEnv` (`.env.redis.example`) @@ -148,7 +160,7 @@ Code: `src/Bootstrap/Handler/BuildMailerFromEnv.php:24-51`. ### No ENV at all -Four of the ten `Bootstrap/Handler/*` closures read no ENV key at all. +Four of the eleven `Bootstrap/Handler/*` closures read no ENV key at all. `BuildFilesystemFromEnv`, `BuildEventListenerProviderFromEnv` and `BuildEventDispatcherFromProvider` activate purely on the matching adapter package being installed (`class_exists(...)`). `ExtractPdoFromConnection` @@ -170,7 +182,7 @@ grep -rhoE "prefix \. '[a-z0-9_]+'" src/Bootstrap/Handler/*.php | sed -E "s/.*'( grep -rhoE "'log_[a-z_]+_url'" src/Bootstrap/Handler/*.php | tr -d "'" ``` Run from `core/kernel` (each line piped through `tr 'a-z' 'A-Z' | sort -u`, -45 total, and compared against the tables above). Deliberately drop the +50 total, and compared against the tables above). Deliberately drop the leading `$` from every pattern above (`env(` / `prefix .`, not `$env(` / `$prefix .`) — inside a double-quoted bash string, `\$` is consumed by bash's own escaping before grep ever sees it (a single backslash yields a diff --git a/src/Bootstrap/Handler/BuildConnectionFromEnv.php b/src/Bootstrap/Handler/BuildConnectionFromEnv.php index 75b17b7..3d9b051 100644 --- a/src/Bootstrap/Handler/BuildConnectionFromEnv.php +++ b/src/Bootstrap/Handler/BuildConnectionFromEnv.php @@ -16,7 +16,9 @@ * * Supports mysql, pgsql, sqlite. Creates a ConnectionPool with read * replicas when DB_READER*_HOST is set and jardisadapter/dbconnection - * is installed. Falls back to plain PDO otherwise. + * is installed. Falls back to plain PDO otherwise. Optional pool tuning + * via DB_POOL_* keys ({@see BuildConnectionPoolConfigFromEnv}); without + * any of them the pool is built exactly as before. * * Ported 1:1 from `jardiscore/foundation` (`Handler\ConnectionHandler`, * Kernel-Entkopplung P2). @@ -98,6 +100,10 @@ private function buildPool(Closure $env, string $driver, array $readers): Connec $charset = (string) ($env('db_charset') ?? ($driver === 'pgsql' ? 'utf8' : 'utf8mb4')); try { + // Built first: an invalid DB_POOL_* value fails fast into the + // existing \Throwable fallback below, before any connection opens. + $config = (new BuildConnectionPoolConfigFromEnv())($env); + $factory = new ConnectionFactory(); $writerConn = $driver === 'pgsql' @@ -117,7 +123,9 @@ private function buildPool(Closure $env, string $driver, array $readers): Connec : $factory->mysql($rHost, $rUser, $rPass, $rDb, $rPort, $charset); } - return new ConnectionPool($writerConn, $readerConns); + return $config === null + ? new ConnectionPool($writerConn, $readerConns) + : new ConnectionPool($writerConn, $readerConns, $config); } catch (\Throwable $e) { error_log(sprintf( '[BuildConnectionFromEnv] ConnectionPool build failed, falling back to plain PDO. Reason: %s', diff --git a/src/Bootstrap/Handler/BuildConnectionPoolConfigFromEnv.php b/src/Bootstrap/Handler/BuildConnectionPoolConfigFromEnv.php new file mode 100644 index 0000000..cd3994c --- /dev/null +++ b/src/Bootstrap/Handler/BuildConnectionPoolConfigFromEnv.php @@ -0,0 +1,75 @@ += 1.1.0 + * (`stickyWriterDuringTransaction`); on older versions the resulting unknown + * named argument throws and BuildConnectionFromEnv's existing \Throwable + * fallback to plain PDO applies. + */ +final class BuildConnectionPoolConfigFromEnv +{ + /** @param Closure(string): mixed $env */ + public function __invoke(Closure $env): ?ConnectionPoolConfig + { + if (!class_exists(ConnectionPoolConfig::class)) { + // @codeCoverageIgnoreStart + // jardisadapter/dbconnection is a require-dev dependency of this + // very test suite, so this branch (adapter not installed) is + // structurally unreachable here — documented gap, not a real path + // in this repo's QA. + return null; + // @codeCoverageIgnoreEnd + } + + $arguments = []; + + $validate = $env('db_pool_validate_connections'); + if ($validate !== null) { + $arguments['validateConnections'] = $validate === 'true'; + } + + $cacheTtl = $env('db_pool_health_check_cache_ttl'); + if ($cacheTtl !== null) { + $arguments['healthCheckCacheTtl'] = (int) $cacheTtl; + } + + $negativeCacheTtl = $env('db_pool_health_check_negative_cache_ttl'); + if ($negativeCacheTtl !== null) { + $arguments['healthCheckNegativeCacheTtl'] = (int) $negativeCacheTtl; + } + + $strategy = $env('db_pool_load_balancing_strategy'); + if ($strategy !== null) { + $arguments['loadBalancingStrategy'] = (string) $strategy; + } + + $sticky = $env('db_pool_sticky_writer'); + if ($sticky !== null) { + $arguments['stickyWriterDuringTransaction'] = $sticky === 'true'; + } + + if ($arguments === []) { + return null; + } + + return new ConnectionPoolConfig(...$arguments); + } +} diff --git a/tests/Unit/Bootstrap/Handler/BuildConnectionFromEnvTest.php b/tests/Unit/Bootstrap/Handler/BuildConnectionFromEnvTest.php index c1e705f..93a6252 100644 --- a/tests/Unit/Bootstrap/Handler/BuildConnectionFromEnvTest.php +++ b/tests/Unit/Bootstrap/Handler/BuildConnectionFromEnvTest.php @@ -88,6 +88,35 @@ public function testReaderConfigurationWithUnreachableHostsFallsBackToNull(): vo self::assertNull($connection); } + public function testPoolEnvWithStickyWriterKeyStillFallsBackToNullOnUnreachableHosts(): void + { + // DB_POOL_* keys only tune the pool config — they change nothing about + // the existing \Throwable fallback: unreachable writer/reader still + // falls back to buildPdo(), which also fails -> null overall. + $connection = (new BuildConnectionFromEnv())($this->envFrom([ + 'db_host' => 'nonexistent_host_that_does_not_exist', + 'db_reader1_host' => 'nonexistent_reader_host_that_does_not_exist', + 'db_pool_sticky_writer' => 'true', + ])); + + self::assertNull($connection); + } + + public function testInvalidPoolStrategyEnvFallsBackViaExistingThrowableCatch(): void + { + // The adapter-side InvalidArgumentException from an invalid + // DB_POOL_LOAD_BALANCING_STRATEGY is thrown before any connection + // opens and lands in the existing fallback (plain PDO on db_host, + // unreachable here -> null). No exception escapes the handler. + $connection = (new BuildConnectionFromEnv())($this->envFrom([ + 'db_host' => 'nonexistent_host_that_does_not_exist', + 'db_reader1_host' => 'nonexistent_reader_host_that_does_not_exist', + 'db_pool_load_balancing_strategy' => 'no-such-strategy', + ])); + + self::assertNull($connection); + } + public function testConnectionPoolInterfaceIsTheDocumentedReturnType(): void { // Compile-time / static documentation check — the union return type diff --git a/tests/Unit/Bootstrap/Handler/BuildConnectionPoolConfigFromEnvTest.php b/tests/Unit/Bootstrap/Handler/BuildConnectionPoolConfigFromEnvTest.php new file mode 100644 index 0000000..d8116bc --- /dev/null +++ b/tests/Unit/Bootstrap/Handler/BuildConnectionPoolConfigFromEnvTest.php @@ -0,0 +1,168 @@ += 1.1) — no + * network, no Docker dependency, same constraint as the sibling test suites. + */ +final class BuildConnectionPoolConfigFromEnvTest extends TestCase +{ + public function testNoPoolKeysReturnsNullSoPoolKeepsAdapterDefaults(): void + { + // Null means BuildConnectionFromEnv keeps the two-argument + // ConnectionPool construction — behaviour unchanged (A6). + $config = (new BuildConnectionPoolConfigFromEnv())($this->envFrom([ + 'db_host' => 'writer-host', + 'db_reader1_host' => 'reader-host', + ])); + + self::assertNull($config); + } + + public function testStickyWriterTrueBuildsConfigCarryingTheFlag(): void + { + $config = (new BuildConnectionPoolConfigFromEnv())($this->envFrom([ + 'db_pool_sticky_writer' => 'true', + ])); + + self::assertInstanceOf(ConnectionPoolConfig::class, $config); + self::assertTrue($config->stickyWriterDuringTransaction); + // Unset keys keep the adapter defaults. + self::assertTrue($config->validateConnections); + self::assertSame(30, $config->healthCheckCacheTtl); + self::assertSame(0, $config->healthCheckNegativeCacheTtl); + self::assertSame(ConnectionPoolConfig::STRATEGY_ROUND_ROBIN, $config->loadBalancingStrategy); + } + + public function testBoolKeysFollowKernelStringComparisonOnlyLiteralTrueIsTrue(): void + { + $config = (new BuildConnectionPoolConfigFromEnv())($this->envFrom([ + 'db_pool_sticky_writer' => '1', + 'db_pool_validate_connections' => 'false', + ])); + + self::assertInstanceOf(ConnectionPoolConfig::class, $config); + self::assertFalse($config->stickyWriterDuringTransaction); + self::assertFalse($config->validateConnections); + } + + public function testSingleIntKeyOnlyThatFieldDeviatesFromDefaults(): void + { + $config = (new BuildConnectionPoolConfigFromEnv())($this->envFrom([ + 'db_pool_health_check_cache_ttl' => '5', + ])); + + self::assertInstanceOf(ConnectionPoolConfig::class, $config); + self::assertSame(5, $config->healthCheckCacheTtl); + self::assertTrue($config->validateConnections); + self::assertSame(0, $config->healthCheckNegativeCacheTtl); + self::assertSame(ConnectionPoolConfig::STRATEGY_ROUND_ROBIN, $config->loadBalancingStrategy); + self::assertFalse($config->stickyWriterDuringTransaction); + } + + public function testSingleStrategyKeyOnlyThatFieldDeviatesFromDefaults(): void + { + $config = (new BuildConnectionPoolConfigFromEnv())($this->envFrom([ + 'db_pool_load_balancing_strategy' => 'random', + ])); + + self::assertInstanceOf(ConnectionPoolConfig::class, $config); + self::assertSame(ConnectionPoolConfig::STRATEGY_RANDOM, $config->loadBalancingStrategy); + self::assertTrue($config->validateConnections); + self::assertSame(30, $config->healthCheckCacheTtl); + self::assertSame(0, $config->healthCheckNegativeCacheTtl); + self::assertFalse($config->stickyWriterDuringTransaction); + } + + public function testNegativeCacheTtlKeyIsPassedThrough(): void + { + $config = (new BuildConnectionPoolConfigFromEnv())($this->envFrom([ + 'db_pool_health_check_negative_cache_ttl' => '10', + ])); + + self::assertInstanceOf(ConnectionPoolConfig::class, $config); + self::assertSame(10, $config->healthCheckNegativeCacheTtl); + self::assertSame(30, $config->healthCheckCacheTtl); + } + + public function testInvalidStrategyPropagatesAdapterValidation(): void + { + // BuildConnectionFromEnv catches this in its existing \Throwable + // fallback (plain PDO + error_log); the handler itself stays thin + // and does not re-validate what the adapter already validates. + $this->expectException(InvalidArgumentException::class); + + (new BuildConnectionPoolConfigFromEnv())($this->envFrom([ + 'db_pool_load_balancing_strategy' => 'no-such-strategy', + ])); + } + + public function testStickyWriterConfigReturnsWriterDuringOpenTransaction(): void + { + $config = (new BuildConnectionPoolConfigFromEnv())($this->envFrom([ + 'db_pool_sticky_writer' => 'true', + ])); + self::assertInstanceOf(ConnectionPoolConfig::class, $config); + + $factory = new ConnectionFactory(); + $writer = $factory->sqlite(':memory:'); + $reader = $factory->sqlite(':memory:'); + $pool = new ConnectionPool($writer, [$reader], $config); + + $writer->beginTransaction(); + + try { + self::assertSame($writer, $pool->getReader()); + } finally { + $writer->rollback(); + } + + // Transaction closed — reads go back to the reader. + self::assertSame($reader, $pool->getReader()); + } + + public function testWithoutStickyConfigPoolReturnsReaderDespiteOpenTransaction(): void + { + // Control case: null config = adapter defaults (sticky off) — the + // pre-change behaviour BuildConnectionFromEnv keeps without DB_POOL_*. + $config = (new BuildConnectionPoolConfigFromEnv())($this->envFrom([])); + self::assertNull($config); + + $factory = new ConnectionFactory(); + $writer = $factory->sqlite(':memory:'); + $reader = $factory->sqlite(':memory:'); + $pool = new ConnectionPool($writer, [$reader]); + + $writer->beginTransaction(); + + try { + self::assertSame($reader, $pool->getReader()); + } finally { + $writer->rollback(); + } + } + + /** + * @param array $data + * @return Closure(string): mixed + */ + private function envFrom(array $data): Closure + { + return static fn (string $key): mixed => $data[strtolower($key)] ?? null; + } +}