Skip to content

Commit 74bb6da

Browse files
committed
Connection に query_timeout_ms の lazy apply を追加 (Phase 5-1 サブ D-2)
- Pool config に `query_timeout_ms` を追加 (PoolConfig.queryTimeoutMs + Resolver バリデーション) - Connection が初回クエリ前に dialect 検出後 SET SESSION を 1 回発行 - MySQL: SET SESSION max_execution_time = N (ms 単位) - MariaDB: SET SESSION max_statement_time = N/1000 (3 桁小数の秒単位) - ConnectionManager の primary/replica 経路で applyQueryTimeout を呼び、 probeReplicas は対象外(短命 probe で SET SESSION の round trip を発生させない) - SET SESSION 失敗時は logger があれば error ログを出力して伝搬
1 parent 6477283 commit 74bb6da

8 files changed

Lines changed: 648 additions & 10 deletions

File tree

src/Sloop/Database/Config/ConnectionConfigResolver.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ final class ConnectionConfigResolver
6363
'log_bindings',
6464
'log_all_queries',
6565
'slow_query_threshold_ms',
66+
'query_timeout_ms',
6667
];
6768

6869
/**
@@ -197,6 +198,7 @@ public static function validatePool(string $name, array $config): PoolConfig
197198
$logBindings = self::extractOptionalBool($name, $config, 'log_bindings') ?? self::DEFAULT_LOG_BINDINGS;
198199
$logAllQueries = self::extractOptionalBool($name, $config, 'log_all_queries') ?? self::DEFAULT_LOG_ALL_QUERIES;
199200
$slowThresholdMs = self::extractOptionalPositiveInt($name, $config, 'slow_query_threshold_ms');
201+
$queryTimeoutMs = self::extractOptionalPositiveInt($name, $config, 'query_timeout_ms');
200202

201203
return new PoolConfig(
202204
name: $name,
@@ -209,6 +211,7 @@ public static function validatePool(string $name, array $config): PoolConfig
209211
logBindings: $logBindings,
210212
logAllQueries: $logAllQueries,
211213
slowQueryThresholdMs: $slowThresholdMs,
214+
queryTimeoutMs: $queryTimeoutMs,
212215
);
213216
}
214217

src/Sloop/Database/Config/PoolConfig.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@
2828
* @param bool $logBindings Whether prepared-statement bindings appear in log context
2929
* @param bool $logAllQueries Whether every query is logged at `debug` level
3030
* @param int|null $slowQueryThresholdMs Threshold (ms) above which SELECT queries log at `warning`
31+
* @param int|null $queryTimeoutMs Per-session query timeout in ms; null disables it.
32+
* Connection issues a single SET SESSION (max_execution_time
33+
* for MySQL, max_statement_time for MariaDB) lazily on the
34+
* first query after dialect detection
3135
*/
3236
public function __construct(
3337
public string $name,
@@ -40,6 +44,7 @@ public function __construct(
4044
public bool $logBindings,
4145
public bool $logAllQueries,
4246
public ?int $slowQueryThresholdMs,
47+
public ?int $queryTimeoutMs,
4348
) {
4449
}
4550
}

src/Sloop/Database/Connection.php

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,24 @@ final class Connection
7171
*/
7272
private LoggingOptions $loggingOptions;
7373

74+
/**
75+
* Per-session query timeout in milliseconds; null disables it. Set via setQueryTimeoutMs().
76+
*
77+
* @var int|null
78+
*/
79+
private ?int $queryTimeoutMs = null;
80+
81+
/**
82+
* Whether the configured timeout has already been issued to the server on this connection.
83+
*
84+
* Toggled true after a successful SET SESSION (max_execution_time on MySQL,
85+
* max_statement_time on MariaDB) so subsequent queries skip the redundant
86+
* statement. Reset to false when setQueryTimeoutMs() is called again.
87+
*
88+
* @var bool
89+
*/
90+
private bool $queryTimeoutApplied = false;
91+
7492
/**
7593
* Construct with an already-configured PDO.
7694
*
@@ -102,6 +120,28 @@ public function setLogger(LoggerInterface $logger, LoggingOptions $options): voi
102120
$this->loggingOptions = $options;
103121
}
104122

123+
/**
124+
* Set the per-session query timeout in milliseconds (null disables it).
125+
*
126+
* Stored on the Connection but not issued to the server until the first
127+
* query() or statement() call: dialect detection (`SELECT VERSION()`)
128+
* has to run first to choose between MySQL `max_execution_time` and
129+
* MariaDB `max_statement_time`. Calling this resets the "applied" flag
130+
* so a fresh value will take effect on the next query — but a previously
131+
* applied value remains in effect on the server session until the next
132+
* SET SESSION fires (passing null does not retroactively clear it).
133+
*
134+
* Intended for one-time configuration before the first query runs.
135+
*
136+
* @param int|null $ms Timeout in milliseconds (positive int) or null to disable
137+
* @return void
138+
*/
139+
public function setQueryTimeoutMs(?int $ms): void
140+
{
141+
$this->queryTimeoutMs = $ms;
142+
$this->queryTimeoutApplied = false;
143+
}
144+
105145
/**
106146
* Open a new Connection from a DSN, applying sloop's PDO defaults internally.
107147
*
@@ -150,6 +190,8 @@ public static function open(
150190
*/
151191
public function query(string $sql, array $bindings = []): Result
152192
{
193+
$this->applyQueryTimeoutIfPending();
194+
153195
$startTime = $this->shouldMeasureElapsed() ? microtime(true) : null;
154196

155197
try {
@@ -187,6 +229,8 @@ public function query(string $sql, array $bindings = []): Result
187229
*/
188230
public function statement(string $sql, array $bindings = []): int
189231
{
232+
$this->applyQueryTimeoutIfPending();
233+
190234
$startTime = $this->shouldMeasureElapsed() ? microtime(true) : null;
191235

192236
try {
@@ -486,6 +530,49 @@ private function execSimple(string $sql): void
486530
}
487531
}
488532

533+
/**
534+
* Issue the configured per-session query timeout to the server on the first call.
535+
*
536+
* The timeout statement differs between server flavors (MySQL uses
537+
* `max_execution_time` in milliseconds and applies only to SELECT;
538+
* MariaDB uses `max_statement_time` in fractional seconds and applies
539+
* to every statement), so dialect detection has to run before the
540+
* SET SESSION can be assembled. The result is cached in
541+
* $queryTimeoutApplied so subsequent queries do not re-issue it.
542+
*
543+
* Failures here are logged at `error` level when a logger is present
544+
* and then re-thrown so the caller learns the timeout was not applied.
545+
*
546+
* @return void
547+
* @throws DatabaseException When dialect detection or the SET SESSION fails
548+
*/
549+
private function applyQueryTimeoutIfPending(): void
550+
{
551+
if ($this->queryTimeoutMs === null || $this->queryTimeoutApplied) {
552+
return;
553+
}
554+
555+
try {
556+
$sql = match ($this->dialect()) {
557+
Dialect::MySQL => 'SET SESSION max_execution_time = ' . $this->queryTimeoutMs,
558+
Dialect::MariaDB => 'SET SESSION max_statement_time = ' . \sprintf('%.3F', $this->queryTimeoutMs / 1000),
559+
};
560+
$this->execSimple($sql);
561+
$this->queryTimeoutApplied = true;
562+
} catch (DatabaseException $e) {
563+
$this->logger?->error(
564+
'failed to apply query timeout: ' . $e->getMessage(),
565+
[
566+
'sqlstate' => $e->sqlState,
567+
'driver_code' => $e->driverCode,
568+
'connection_name' => $this->connectionName,
569+
],
570+
);
571+
572+
throw $e;
573+
}
574+
}
575+
489576
/**
490577
* Prepare and execute $sql with $bindings, wrapping any PDOException.
491578
*

src/Sloop/Database/ConnectionManager.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ private function getPrimaryConnection(string $name): Connection
123123

124124
$connection = $this->factory->make($pool->primary, $name);
125125
$this->applyLogger($connection, $pool);
126+
$this->applyQueryTimeout($connection, $pool);
126127
$this->primaryConnections[$name] = $connection;
127128
}
128129

@@ -172,6 +173,7 @@ private function getReplicaConnection(string $name): Connection
172173
try {
173174
$connection = $this->factory->make($picked, $name);
174175
$this->applyLogger($connection, $pool);
176+
$this->applyQueryTimeout($connection, $pool);
175177

176178
if ($pool->healthCheck) {
177179
$connection->ping();
@@ -268,6 +270,27 @@ private function applyLogger(Connection $connection, PoolConfig $pool): void
268270
);
269271
}
270272

273+
/**
274+
* Forward the pool's query timeout into the Connection so it can lazy-apply on the first query.
275+
*
276+
* Skipped when the pool does not configure a timeout. Probe connections
277+
* built by probeReplicas() are intentionally bypassed: they only run a
278+
* `DO 1` ping and would just burn an extra round trip on a dialect probe
279+
* plus SET SESSION before being discarded.
280+
*
281+
* @param Connection $connection Newly built Connection that has not yet executed a query
282+
* @param PoolConfig $pool Pool config supplying queryTimeoutMs
283+
* @return void
284+
*/
285+
private function applyQueryTimeout(Connection $connection, PoolConfig $pool): void
286+
{
287+
if ($pool->queryTimeoutMs === null) {
288+
return;
289+
}
290+
291+
$connection->setQueryTimeoutMs($pool->queryTimeoutMs);
292+
}
293+
271294
/**
272295
* Resolve and validate the pool config for the given name.
273296
*

tests/Unit/Database/Config/ConnectionConfigResolverTest.php

Lines changed: 96 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -560,12 +560,14 @@ public function testValidatePoolAcceptsAllPoolBehaviorKeys(): void
560560
'dead_cache_ttl_seconds' => 60,
561561
'replica_selector' => 'random',
562562
'max_connection_attempts' => 6,
563+
'query_timeout_ms' => 5000,
563564
]);
564565

565566
$this->assertFalse($pool->healthCheck);
566567
$this->assertSame(60, $pool->deadCacheTtlSeconds);
567568
$this->assertSame('random', $pool->replicaSelector);
568569
$this->assertSame(6, $pool->maxConnectionAttempts);
570+
$this->assertSame(5000, $pool->queryTimeoutMs);
569571
}
570572

571573
public function testValidatePoolDefaultsMaxConnectionAttemptsToReplicaCountPlusOne(): void
@@ -588,15 +590,15 @@ public function testValidatePoolRejectsUnknownPoolKey(): void
588590
{
589591
try {
590592
ConnectionConfigResolver::validatePool('mydb', [
591-
'driver' => 'mysql',
592-
'host' => 'primary.example.com',
593-
'database' => 'app',
594-
'query_timeout_ms' => 5000,
593+
'driver' => 'mysql',
594+
'host' => 'primary.example.com',
595+
'database' => 'app',
596+
'persistent' => true,
595597
]);
596598
$this->fail('Expected InvalidConfigException');
597599
} catch (InvalidConfigException $e) {
598600
$this->assertSame(
599-
'Connection [mydb]: unsupported config key "query_timeout_ms".',
601+
'Connection [mydb]: unsupported config key "persistent".',
600602
$e->getMessage(),
601603
);
602604
}
@@ -886,6 +888,7 @@ public function testValidatePoolDefaultsLoggingKeysWhenOmitted(): void
886888
$this->assertTrue($pool->logBindings);
887889
$this->assertFalse($pool->logAllQueries);
888890
$this->assertNull($pool->slowQueryThresholdMs);
891+
$this->assertNull($pool->queryTimeoutMs);
889892
}
890893

891894
public function testValidatePoolAcceptsExplicitLogBindings(): void
@@ -1042,4 +1045,92 @@ public function testValidatePoolAcceptsExplicitNullMaxConnectionAttempts(): void
10421045

10431046
$this->assertSame(3, $pool->maxConnectionAttempts);
10441047
}
1048+
1049+
/**
1050+
* @return array<string, array{0: int|null, 1: int|null}>
1051+
*/
1052+
public static function validQueryTimeoutMsProvider(): array
1053+
{
1054+
return [
1055+
'positive int' => [5000, 5000],
1056+
'minimum 1' => [1, 1],
1057+
'explicit null' => [null, null],
1058+
];
1059+
}
1060+
1061+
#[DataProvider('validQueryTimeoutMsProvider')]
1062+
public function testValidatePoolAcceptsValidQueryTimeoutMs(?int $input, ?int $expected): void
1063+
{
1064+
// Valid inputs: positive int (typical), 1 (boundary inclusion guarding
1065+
// a `> 1` regression), and explicit null (the "off" sentinel shared
1066+
// with slow_query_threshold_ms / dead_cache_ttl_seconds).
1067+
$pool = ConnectionConfigResolver::validatePool('mydb', [
1068+
'driver' => 'mysql',
1069+
'host' => 'primary.example.com',
1070+
'database' => 'app',
1071+
'query_timeout_ms' => $input,
1072+
]);
1073+
1074+
$this->assertSame($expected, $pool->queryTimeoutMs);
1075+
}
1076+
1077+
/**
1078+
* @return array<string, array{0: mixed, 1: string}>
1079+
*/
1080+
public static function invalidQueryTimeoutMsProvider(): array
1081+
{
1082+
return [
1083+
'non-int string' => ['5000', 'must be an integer'],
1084+
'float' => [1.5, 'must be an integer'],
1085+
'bool true' => [true, 'must be an integer'],
1086+
'zero' => [0, 'must be >= 1, got 0'],
1087+
'negative' => [-1, 'must be >= 1, got -1'],
1088+
];
1089+
}
1090+
1091+
#[DataProvider('invalidQueryTimeoutMsProvider')]
1092+
public function testValidatePoolRejectsInvalidQueryTimeoutMs(mixed $value, string $expectedSuffix): void
1093+
{
1094+
// The two error-message templates correspond to the two rejection
1095+
// branches in extractOptionalPositiveInt: type check (`!is_int`) and
1096+
// range check (`< 1`). Bool/float are exercised explicitly so silent
1097+
// coercion regressions are caught.
1098+
try {
1099+
ConnectionConfigResolver::validatePool('mydb', [
1100+
'driver' => 'mysql',
1101+
'host' => 'primary.example.com',
1102+
'database' => 'app',
1103+
'query_timeout_ms' => $value,
1104+
]);
1105+
$this->fail('Expected InvalidConfigException');
1106+
} catch (InvalidConfigException $e) {
1107+
$this->assertSame(
1108+
'Connection [mydb]: config key "query_timeout_ms" ' . $expectedSuffix . '.',
1109+
$e->getMessage(),
1110+
);
1111+
}
1112+
}
1113+
1114+
public function testValidatePoolRejectsQueryTimeoutMsInsideReplica(): void
1115+
{
1116+
// query_timeout_ms is pool-level: it must be set on the pool itself,
1117+
// never inside a read[] entry. Same protection as the health_check
1118+
// regression in testValidatePoolRejectsPoolOnlyKeyInsideReplica.
1119+
try {
1120+
ConnectionConfigResolver::validatePool('mydb', [
1121+
'driver' => 'mysql',
1122+
'host' => 'primary.example.com',
1123+
'database' => 'app',
1124+
'read' => [
1125+
['host' => 'replica-1.example.com', 'query_timeout_ms' => 5000],
1126+
],
1127+
]);
1128+
$this->fail('Expected InvalidConfigException');
1129+
} catch (InvalidConfigException $e) {
1130+
$this->assertSame(
1131+
'Connection [mydb]: "read[0]" has unsupported key "query_timeout_ms". Pool-level keys must be set on the pool itself, not inside read[].',
1132+
$e->getMessage(),
1133+
);
1134+
}
1135+
}
10451136
}

tests/Unit/Database/Config/PoolConfigTest.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ public function testStoresAllFields(): void
2727
logBindings: false,
2828
logAllQueries: true,
2929
slowQueryThresholdMs: 200,
30+
queryTimeoutMs: 5000,
3031
);
3132

3233
$this->assertSame('mydb', $pool->name);
@@ -39,6 +40,7 @@ public function testStoresAllFields(): void
3940
$this->assertFalse($pool->logBindings);
4041
$this->assertTrue($pool->logAllQueries);
4142
$this->assertSame(200, $pool->slowQueryThresholdMs);
43+
$this->assertSame(5000, $pool->queryTimeoutMs);
4244
}
4345

4446
public function testStoresEmptyReplicas(): void
@@ -54,6 +56,7 @@ public function testStoresEmptyReplicas(): void
5456
logBindings: true,
5557
logAllQueries: false,
5658
slowQueryThresholdMs: null,
59+
queryTimeoutMs: null,
5760
);
5861

5962
$this->assertSame([], $pool->replicas);
@@ -63,6 +66,7 @@ public function testStoresEmptyReplicas(): void
6366
$this->assertTrue($pool->logBindings);
6467
$this->assertFalse($pool->logAllQueries);
6568
$this->assertNull($pool->slowQueryThresholdMs);
69+
$this->assertNull($pool->queryTimeoutMs);
6670
}
6771

6872
public function testStoresSingleReplica(): void
@@ -81,6 +85,7 @@ public function testStoresSingleReplica(): void
8185
logBindings: true,
8286
logAllQueries: false,
8387
slowQueryThresholdMs: null,
88+
queryTimeoutMs: null,
8489
);
8590

8691
$this->assertCount(1, $pool->replicas);

0 commit comments

Comments
 (0)