Skip to content

Commit 5c0a152

Browse files
committed
ConnectionManager にハイブリッド読み書きルーティングを実装 (Phase 5-1 サブ C-4)
connection(?bool $writable = null) で primary/replica を明示的にルーティング。 replica 選択フロー: dead cache フィルタ → ReplicaSelector → 接続試行 → DO 1 ping (health_check 有効時) → 失敗時 dead cache 記録 (SQLSTATE 28000 は pool 単位、それ以外は server 単位) → 次の replica → 全滅で primary フォール バック → max_connection_attempts 枯渇で DatabaseConnectionException (累積 エラー詳細付き)。 null/true は primary、false は replica。null で SELECT 自動検出は行わず、 それは Builder 層の責務として Phase 5-2 以降に委ねる。read=[] 時は connection(writable:false) も primary を返す (単一プール運用との互換)。 ReplicaSelector::pick() は non-empty-list を受け取り int を返す契約に変更 (coding-standards.md「\assert() で型保証しない」方針に準拠、設計レベルで 非空を保証)。RandomReplicaSelector の empty 早期 return も同時に削除。 Application::registerCoreBindings に ReplicaSelector (RandomReplicaSelector singleton) と DeadReplicaCache (ApcuDeadReplicaCache::isAvailable() で Apcu/InMemory 切替) を追加し、ConnectionManager に DI 注入する。 DeadReplicaCache の選択ロジックは createDeadReplicaCache() に抽出して 他の create*() メソッドと体裁を揃えた。 例外メッセージは「Failed to obtain a read connection (replica + primary fallback exhausted)」として primary フォールバック失敗も含む状況を明示。 dead-cache でスキップされた replica は「host:port → skipped (dead-cache)」 形式で累積エラーに含めるため、運用診断時にスキップ理由まで一目で把握できる。 dead-cache フィルタは partitionByDeadCache() ヘルパーに切り出した。 テスト: ConnectionManagerTest に 14 ケース、ApplicationTest に 8 ケース (singleton / default / invalid / override 反映を 2 系列ずつ) を追加。 Stub に FixedReplicaSelector / ScriptedConnectionFactory を新設してシナリオ ベース検証を可能にした。RandomReplicaSelectorTest は non-empty-list 契約に 合わせて見直し。ping failure テストは PDO mock + \PDOException 投擲で再現 することで SQLite の DO 文挙動依存を排除しつつ、両 pool で dead 状態を assert する。
1 parent b32f631 commit 5c0a152

10 files changed

Lines changed: 1061 additions & 139 deletions

File tree

src/Sloop/Database/ConnectionManager.php

Lines changed: 199 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,14 @@
55
namespace Sloop\Database;
66

77
use Sloop\Database\Config\ConnectionConfigResolver;
8+
use Sloop\Database\Config\PoolConfig;
9+
use Sloop\Database\Config\ValidatedConfig;
810
use Sloop\Database\Exception\DatabaseConnectionException;
11+
use Sloop\Database\Exception\DatabaseException;
912
use Sloop\Database\Exception\InvalidConfigException;
1013
use Sloop\Database\Factory\ConnectionFactory;
14+
use Sloop\Database\Replica\DeadReplicaCache;
15+
use Sloop\Database\Replica\ReplicaSelector;
1116

1217
/**
1318
* Lazily creates and caches database connections from pool configurations.
@@ -18,66 +23,240 @@
1823
* the injected ConnectionFactory the first time they are requested and
1924
* cached so a single request reuses one PDO instance per pool name.
2025
*
21-
* connection() currently returns the pool's primary; replica selection and
22-
* the writable parameter are not yet implemented.
26+
* connection() routing:
27+
* - $writable === true → primary
28+
* - $writable === false → replica (dead-cache filter → ReplicaSelector → ping
29+
* → record on failure → next → primary fallback
30+
* → throw on max_connection_attempts exhaustion)
31+
* - $writable === null → primary (Builder layer detects SELECT, not the manager)
32+
*
33+
* Empty `read` list collapses replica routing to the primary so single-pool
34+
* setups keep working without reconfiguration.
2335
*/
2436
final class ConnectionManager
2537
{
2638
/**
27-
* Cached Connection instances keyed by pool name.
39+
* Cached primary Connection instances keyed by pool name.
2840
*
2941
* @var array<string, Connection>
3042
*/
31-
private array $connections = [];
43+
private array $primaryConnections = [];
44+
45+
/**
46+
* Cached replica Connection instances keyed by pool name.
47+
*
48+
* Replica selection runs at most once per pool per request: once a healthy
49+
* replica is found, subsequent connection(writable: false) calls reuse it.
50+
*
51+
* @var array<string, Connection>
52+
*/
53+
private array $replicaConnections = [];
3254

3355
/**
3456
* Construct a new ConnectionManager.
3557
*
36-
* @param string $defaultName Pool name to return from connection()
37-
* @param array<string, array<string, mixed>> $configs Pool configurations indexed by pool name
38-
* @param ConnectionFactory $factory Builds Connection instances from validated configs
58+
* @param string $defaultName Pool name to return from connection()
59+
* @param array<string, array<string, mixed>> $configs Pool configurations indexed by pool name
60+
* @param ConnectionFactory $factory Builds Connection instances from validated configs
61+
* @param ReplicaSelector $replicaSelector Strategy for picking one replica from surviving candidates
62+
* @param DeadReplicaCache $deadCache Negative cache for replicas that recently failed to connect
3963
*/
4064
public function __construct(
4165
private readonly string $defaultName,
4266
private readonly array $configs,
4367
private readonly ConnectionFactory $factory,
68+
private readonly ReplicaSelector $replicaSelector,
69+
private readonly DeadReplicaCache $deadCache,
4470
) {
4571
}
4672

4773
/**
48-
* Return the default pool's primary connection, creating and caching it on first call.
74+
* Return the default pool's connection, routing to primary or replica based on $writable.
4975
*
50-
* @return Connection Lazy-created, cached Connection to the default pool's primary
76+
* @param bool|null $writable true → primary; false → replica with primary fallback;
77+
* null → primary (Builder layer is responsible for SELECT detection)
78+
* @return Connection Lazy-created, cached Connection
5179
* @throws InvalidConfigException When the default pool name is not defined or its config is malformed
52-
* @throws DatabaseConnectionException When the underlying PDO connection cannot be established
80+
* @throws DatabaseConnectionException When max_connection_attempts is exhausted on the replica path
5381
*/
54-
public function connection(): Connection
82+
public function connection(?bool $writable = null): Connection
5583
{
56-
if (!isset($this->connections[$this->defaultName])) {
57-
$this->connections[$this->defaultName] = $this->makeConnection($this->defaultName);
84+
if ($writable === false) {
85+
return $this->getReplicaConnection($this->defaultName);
5886
}
5987

60-
return $this->connections[$this->defaultName];
88+
return $this->getPrimaryConnection($this->defaultName);
6189
}
6290

6391
/**
64-
* Build a new Connection to the named pool's primary via the injected factory.
92+
* Return the cached primary Connection or build it on first access.
6593
*
6694
* @param string $name Pool name
6795
* @return Connection
68-
* @throws InvalidConfigException When the name is undefined or the config is malformed
69-
* @throws DatabaseConnectionException When the underlying PDO connection cannot be established
96+
* @throws InvalidConfigException When the name is undefined or config is malformed
97+
* @throws DatabaseConnectionException When the underlying PDO connection fails
7098
*/
71-
private function makeConnection(string $name): Connection
99+
private function getPrimaryConnection(string $name): Connection
100+
{
101+
if (!isset($this->primaryConnections[$name])) {
102+
$pool = $this->resolvePool($name);
103+
104+
$this->primaryConnections[$name] = $this->factory->make($pool->primary, $name);
105+
}
106+
107+
return $this->primaryConnections[$name];
108+
}
109+
110+
/**
111+
* Return the cached replica Connection, or run the selection loop on first access.
112+
*
113+
* Selection flow:
114+
* 1. Filter out replicas marked dead in the cache
115+
* 2. ReplicaSelector picks one of the survivors
116+
* 3. Connect via ConnectionFactory; if health_check is on, verify with Connection::ping()
117+
* 4. On failure: record dead (auth → markPoolDead, otherwise markServerDead)
118+
* and remove the candidate; loop until success, candidates exhausted,
119+
* or max_connection_attempts reached
120+
* 5. When all replicas fail and attempts remain, fall back to primary
121+
* 6. If no healthy connection is found, throw DatabaseConnectionException
122+
* with cumulative per-attempt error details
123+
*
124+
* @param string $name Pool name
125+
* @return Connection
126+
* @throws InvalidConfigException When the name is undefined or config is malformed
127+
* @throws DatabaseConnectionException When all attempts (replica + fallback) fail
128+
*/
129+
private function getReplicaConnection(string $name): Connection
130+
{
131+
if (isset($this->replicaConnections[$name])) {
132+
return $this->replicaConnections[$name];
133+
}
134+
135+
$pool = $this->resolvePool($name);
136+
137+
if ($pool->replicas === []) {
138+
// No replicas configured → primary doubles as the read endpoint.
139+
return $this->getPrimaryConnection($name);
140+
}
141+
142+
[$candidates, $errors] = $this->partitionByDeadCache($pool);
143+
$attempts = 0;
144+
145+
while ($candidates !== [] && $attempts < $pool->maxConnectionAttempts) {
146+
$index = $this->replicaSelector->pick($candidates);
147+
$picked = $candidates[$index];
148+
$attempts++;
149+
150+
try {
151+
$connection = $this->factory->make($picked, $name);
152+
153+
if ($pool->healthCheck) {
154+
$connection->ping();
155+
}
156+
157+
$this->replicaConnections[$name] = $connection;
158+
159+
return $connection;
160+
} catch (DatabaseException $e) {
161+
$errors[] = $this->formatAttemptError($picked, $e);
162+
$this->recordReplicaFailure($pool, $picked, $e);
163+
array_splice($candidates, $index, 1);
164+
}
165+
}
166+
167+
if ($attempts < $pool->maxConnectionAttempts) {
168+
try {
169+
return $this->getPrimaryConnection($name);
170+
} catch (DatabaseException $e) {
171+
$errors[] = $this->formatAttemptError($pool->primary, $e);
172+
}
173+
}
174+
175+
throw new DatabaseConnectionException(
176+
'Failed to obtain a read connection for pool [' . $name . '] (replica + primary fallback exhausted): ' . implode(' | ', $errors),
177+
$name,
178+
);
179+
}
180+
181+
/**
182+
* Resolve and validate the pool config for the given name.
183+
*
184+
* @param string $name Pool name
185+
* @return PoolConfig
186+
* @throws InvalidConfigException When the name is undefined or config is malformed
187+
*/
188+
private function resolvePool(string $name): PoolConfig
72189
{
73190
if (!\array_key_exists($name, $this->configs)) {
74191
throw new InvalidConfigException(
75192
'Database connection [' . $name . '] is not defined.',
76193
);
77194
}
78195

79-
$pool = ConnectionConfigResolver::validatePool($name, $this->configs[$name]);
196+
return ConnectionConfigResolver::validatePool($name, $this->configs[$name]);
197+
}
198+
199+
/**
200+
* Partition replicas into alive candidates and dead-cache skip messages.
201+
*
202+
* Skipped entries are recorded as preformatted error strings so they can be
203+
* appended to the cumulative diagnostic message that surfaces when every
204+
* connection attempt (replica + primary fallback) fails.
205+
*
206+
* @param PoolConfig $pool Pool config (provides replica list and pool name)
207+
* @return array{0: list<ValidatedConfig>, 1: list<string>} [alive in declaration order, skip messages]
208+
*/
209+
private function partitionByDeadCache(PoolConfig $pool): array
210+
{
211+
$alive = [];
212+
$skipped = [];
213+
foreach ($pool->replicas as $replica) {
214+
if ($this->deadCache->isDead($replica->host, $replica->port ?? 0, $pool->name)) {
215+
$skipped[] = $replica->host . ':' . ($replica->port ?? '?') . ' → skipped (dead-cache)';
216+
217+
continue;
218+
}
219+
$alive[] = $replica;
220+
}
221+
222+
return [$alive, $skipped];
223+
}
224+
225+
/**
226+
* Mark a failed replica dead in the negative cache.
227+
*
228+
* Auth failures (SQLSTATE 28000) are pool-specific: the credential is wrong
229+
* only for this pool, so the per-pool key is used. Everything else (TCP
230+
* refused, server unreachable, DO 1 failure) implies the host itself is
231+
* unhealthy and goes to the shared key.
232+
*
233+
* @param PoolConfig $pool Pool config (provides pool name and TTL)
234+
* @param ValidatedConfig $replica The replica that just failed
235+
* @param DatabaseException $e Failure thrown by the factory or ping()
236+
* @return void
237+
*/
238+
private function recordReplicaFailure(PoolConfig $pool, ValidatedConfig $replica, DatabaseException $e): void
239+
{
240+
$port = $replica->port ?? 0;
241+
242+
if ($e instanceof DatabaseConnectionException && $e->sqlState === '28000') {
243+
$this->deadCache->markPoolDead($replica->host, $port, $pool->name, $pool->deadCacheTtlSeconds);
80244

81-
return $this->factory->make($pool->primary, $name);
245+
return;
246+
}
247+
248+
$this->deadCache->markServerDead($replica->host, $port, $pool->deadCacheTtlSeconds);
249+
}
250+
251+
/**
252+
* Format one failed attempt for the cumulative error message.
253+
*
254+
* @param ValidatedConfig $config Target host/port
255+
* @param DatabaseException $e Failure
256+
* @return string
257+
*/
258+
private function formatAttemptError(ValidatedConfig $config, DatabaseException $e): string
259+
{
260+
return $config->host . ':' . ($config->port ?? '?') . '' . $e->getMessage();
82261
}
83262
}

src/Sloop/Database/Replica/RandomReplicaSelector.php

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,11 @@ final class RandomReplicaSelector implements ReplicaSelector
1919
/**
2020
* Pick one candidate uniformly at random and return its index.
2121
*
22-
* @param list<ValidatedConfig> $candidates Surviving replica configs
23-
* @return int|null Index into $candidates, or null when empty
22+
* @param non-empty-list<ValidatedConfig> $candidates Surviving replica configs (must be non-empty)
23+
* @return int Valid index into $candidates
2424
*/
25-
public function pick(array $candidates): ?int
25+
public function pick(array $candidates): int
2626
{
27-
if ($candidates === []) {
28-
return null;
29-
}
30-
3127
return (int) array_rand($candidates);
3228
}
3329
}

src/Sloop/Database/Replica/ReplicaSelector.php

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,14 @@
1717
interface ReplicaSelector
1818
{
1919
/**
20-
* Pick one candidate and return its index, or null when no candidates remain.
20+
* Pick one candidate and return its index.
2121
*
22-
* @param list<ValidatedConfig> $candidates Surviving replica configs
23-
* @return int|null Index into $candidates, or null when empty
22+
* Callers must ensure $candidates is non-empty; ConnectionManager filters
23+
* out dead replicas first and only invokes pick() while at least one
24+
* survivor remains, so this contract is checkable at the call site.
25+
*
26+
* @param non-empty-list<ValidatedConfig> $candidates Surviving replica configs (must be non-empty)
27+
* @return int Valid index into $candidates
2428
*/
25-
public function pick(array $candidates): ?int;
29+
public function pick(array $candidates): int;
2630
}

src/Sloop/Foundation/Application.php

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@
1616
use Sloop\Database\ConnectionManager;
1717
use Sloop\Database\Factory\ConnectionFactory;
1818
use Sloop\Database\Factory\PdoConnectionFactory;
19+
use Sloop\Database\Replica\ApcuDeadReplicaCache;
20+
use Sloop\Database\Replica\DeadReplicaCache;
21+
use Sloop\Database\Replica\InMemoryDeadReplicaCache;
22+
use Sloop\Database\Replica\RandomReplicaSelector;
23+
use Sloop\Database\Replica\ReplicaSelector;
1924
use Sloop\Http\HttpStatus;
2025
use Sloop\Http\Middleware\MiddlewareDispatcher;
2126
use Sloop\Http\Request\Request;
@@ -181,9 +186,27 @@ private function registerCoreBindings(): void
181186
$this->container->singleton(TraceContext::class, fn (): TraceContext => new TraceContext());
182187
$this->container->singleton(LogManager::class, fn (Container $container): LogManager => $this->createLogManager($container));
183188
$this->container->singleton(ConnectionFactory::class, fn (): ConnectionFactory => new PdoConnectionFactory());
189+
$this->container->singleton(ReplicaSelector::class, fn (): ReplicaSelector => new RandomReplicaSelector());
190+
$this->container->singleton(DeadReplicaCache::class, fn (): DeadReplicaCache => $this->createDeadReplicaCache());
184191
$this->container->singleton(ConnectionManager::class, fn (Container $container): ConnectionManager => $this->createConnectionManager($container));
185192
}
186193

194+
/**
195+
* Create the DeadReplicaCache implementation appropriate to the current environment.
196+
*
197+
* Picks ApcuDeadReplicaCache when ext-apcu is loaded and apc.enable_cli=1
198+
* (cross-request dead marks); otherwise falls back to InMemoryDeadReplicaCache
199+
* (per-request dead marks, sufficient as a degraded fallback).
200+
*
201+
* @return DeadReplicaCache
202+
*/
203+
private function createDeadReplicaCache(): DeadReplicaCache
204+
{
205+
return ApcuDeadReplicaCache::isAvailable()
206+
? new ApcuDeadReplicaCache()
207+
: new InMemoryDeadReplicaCache();
208+
}
209+
187210
/**
188211
* Create the ApiResponseFormatter from configuration.
189212
*
@@ -290,9 +313,9 @@ private function resolveChannelFactory(Container $container, string $factoryClas
290313
/**
291314
* Create the ConnectionManager from configuration.
292315
*
293-
* @param Container $container Container used to resolve the ConnectionFactory binding
316+
* @param Container $container Container used to resolve the ConnectionFactory / ReplicaSelector / DeadReplicaCache bindings
294317
* @return ConnectionManager
295-
* @throws \RuntimeException If the ConnectionFactory binding does not implement ConnectionFactory
318+
* @throws \RuntimeException If any required binding does not implement its declared interface
296319
*/
297320
private function createConnectionManager(Container $container): ConnectionManager
298321
{
@@ -315,10 +338,26 @@ private function createConnectionManager(Container $container): ConnectionManage
315338
);
316339
}
317340

341+
$replicaSelector = $container->get(ReplicaSelector::class);
342+
if (!$replicaSelector instanceof ReplicaSelector) {
343+
throw new \RuntimeException(
344+
'Container binding for ' . ReplicaSelector::class . ' must implement ReplicaSelector.',
345+
);
346+
}
347+
348+
$deadCache = $container->get(DeadReplicaCache::class);
349+
if (!$deadCache instanceof DeadReplicaCache) {
350+
throw new \RuntimeException(
351+
'Container binding for ' . DeadReplicaCache::class . ' must implement DeadReplicaCache.',
352+
);
353+
}
354+
318355
return new ConnectionManager(
319356
defaultName: $default,
320357
configs: $connections,
321358
factory: $factory,
359+
replicaSelector: $replicaSelector,
360+
deadCache: $deadCache,
322361
);
323362
}
324363

0 commit comments

Comments
 (0)