Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions docs/env-examples/.env.database.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# -----------------------------------------------------------------------------
Expand Down
40 changes: 26 additions & 14 deletions docs/env-examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`)

Expand Down Expand Up @@ -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`
Expand All @@ -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
Expand Down
12 changes: 10 additions & 2 deletions src/Bootstrap/Handler/BuildConnectionFromEnv.php
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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'
Expand All @@ -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',
Expand Down
75 changes: 75 additions & 0 deletions src/Bootstrap/Handler/BuildConnectionPoolConfigFromEnv.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

declare(strict_types=1);

namespace JardisCore\Kernel\Bootstrap\Handler;

use Closure;
use JardisAdapter\DbConnection\Config\ConnectionPoolConfig;

/**
* Builds an optional ConnectionPoolConfig from ENV values.
*
* Reads the five DB_POOL_* keys. Returns null when none of them is set, so
* {@see BuildConnectionFromEnv} keeps constructing the ConnectionPool without
* an explicit config — behaviour unchanged for existing setups. Keys that are
* set are passed as named arguments; unset keys keep the ConnectionPoolConfig
* defaults (defined by jardisadapter/dbconnection, not duplicated here).
*
* Booleans follow the kernel-wide string comparison (`BuildHttpClientFromEnv`):
* only the literal `true` is true.
*
* `DB_POOL_STICKY_WRITER` requires jardisadapter/dbconnection >= 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);
}
}
29 changes: 29 additions & 0 deletions tests/Unit/Bootstrap/Handler/BuildConnectionFromEnvTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading