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
3 changes: 2 additions & 1 deletion bin/swoole-server
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ $server->on('start', fn (Server $server) => $bootstrap($serverState) && (new OnS
$timerTable,
$serverState['octaneConfig']['swoole']['tick'] ?? false,
true,
$serverState['octaneConfig']['swoole']['timeout_fallback_signal'] ?? SIGTERM
$serverState['octaneConfig']['swoole']['timeout_fallback_signal'] ?? SIGTERM,
max(1000, (int) ($serverState['octaneConfig']['swoole']['timeout_sweep_interval_ms'] ?? 5000))
))($server));

$server->on('managerstart', function () use ($serverState) {
Expand Down
14 changes: 14 additions & 0 deletions config/octane.php
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,20 @@
*/

'swoole' => [
/*
|--------------------------------------------------------------------------
| Request Timeout Sweep Interval
|--------------------------------------------------------------------------
|
| How often (ms) the server scans in-flight requests for ones that
| exceeded max_execution_time. Coarser is cheaper: a request can
| overrun its budget by at most one interval. Clamped to >= 1000.
|
*/

'timeout_sweep_interval_ms' => env('OCTANE_TIMEOUT_SWEEP_INTERVAL_MS', 5000),


/*
|--------------------------------------------------------------------------
| Database Connection Safety Buffer
Expand Down
24 changes: 24 additions & 0 deletions src/Swoole/Database/DatabaseManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,16 @@ protected function armCoroutineExitRelease(): void

\Swoole\Coroutine::defer(function (): void {
try {
// The gc exists to close statement cycles against a
// connection this coroutine is about to release. When the
// Worker already released everything (the normal request
// path), there is nothing to protect - and the root buffer
// is process-global, so the old unconditional check made
// this a full cycle-collection on every DB request.
if (! $this->contextHoldsPooledConnections()) {
return;
}

if (gc_status()['roots'] > 0) {
gc_collect_cycles();
}
Expand All @@ -94,6 +104,20 @@ protected function armCoroutineExitRelease(): void
});
}

/**
* Whether this coroutine's context still holds pool-borrowed connections.
*/
protected function contextHoldsPooledConnections(): bool
{
foreach (Context::all() as $key => $value) {
if (str_ends_with($key, '.pool')) {
return true;
}
}

return false;
}

protected function getPool($name)
{
$this->syncApplication();
Expand Down
74 changes: 57 additions & 17 deletions src/Swoole/Database/DatabasePool.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,13 +98,21 @@ public function get()
$waitTimeout = $this->config['wait_timeout'] ?? 3.0;
$maxConnections = $this->config['max_connections'] ?? 10;

// Fast path: try a non-blocking pop first to avoid unnecessary waits.
$connection = $this->channel->pop(0.001);
// Fast path: only pop when something is pooled - popping an empty
// channel costs a 1ms scheduler wait before checkout can create.
$connection = $this->channel->length() > 0 ? $this->channel->pop(0.001) : false;

if ($connection === false) {
// If we can grow the pool, create immediately instead of waiting.
if ($this->currentConnections < $maxConnections) {
$connection = $this->createConnection();
} elseif ($this->reconcileVanishedConnections() > 0
&& $this->currentConnections < $maxConnections) {
// The throttled prune no longer reconciles on every checkout,
// so a borrower that died without releasing could otherwise
// pin the counter at max for up to prune_interval - turning
// healable slots into 3s waits and pool-exhausted 500s.
$connection = $this->createConnection();
} else {
// Pool is at max; wait for a connection to be released.
$connection = $this->channel->pop($waitTimeout);
Expand Down Expand Up @@ -239,6 +247,21 @@ public function release($connection): void
*/
public function pruneIdleConnections(?float $now = null): int
{
// get() and release() both prune opportunistically, so a busy pool
// would otherwise pay the full drain-and-refill walk several times
// per request. Worse, while the drain holds idle connections in a
// local array, a concurrent checkout that yields into an empty
// channel creates a brand-new connection it never needed. Once a
// second is plenty; the heartbeat timer stays the primary pruner.
$interval = (float) ($this->config['prune_interval'] ?? 1.0);
$clock = $now ?? microtime(true);

if ($interval > 0 && ($clock - $this->lastPruneAt) < $interval) {
return 0;
}

$this->lastPruneAt = $clock;

$this->reconcileVanishedConnections();

$maxIdleTime = (float) ($this->config['max_idle_time'] ?? 60.0);
Expand Down Expand Up @@ -336,18 +359,17 @@ protected function resetConnection($connection): void
$connection->setReadWriteType(null);
}

// Reset session variables for MySQL
// No session SQL for MySQL here: nothing in a request can
// change the isolation level, and the autocommit variable
// never changes either - PDO::beginTransaction runs START
// TRANSACTION, which suspends autocommit for that transaction
// without touching the session variable. normalizeSession()
// covers the two moments a session actually IS new - creation
// and the reconnector's PDO swap. Re-SETting on every release
// was measured at 45 statement-pairs/s in production, each
// pair holding the connection out of the pool for two round
// trips.
$driver = $connection->getDriverName();
if (in_array($driver, ['mysql', 'mariadb'])) {
try {
// Reset session state
$pdo->exec('SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ');
$pdo->exec('SET autocommit = 1');
} catch (Throwable $e) {
// Non-critical, log and continue
error_log('⚠️ Could not reset MySQL session: '.$e->getMessage());
}
}

// For PostgreSQL
if ($driver === 'pgsql') {
Expand Down Expand Up @@ -483,8 +505,17 @@ protected function normalizeSession(Connection $connection): void

try {
$pdo = $connection->getPdo();
$pdo->exec('SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ');
$pdo->exec('SET autocommit = 1');

if ($connection->getDriverName() === 'mariadb') {
// MariaDB before 11.1.1 has no transaction_isolation system
// variable (MDEV-21921), so the combined assignment errors
// wholesale there. The standard-SQL form works everywhere,
// and this only runs when a session is created.
$pdo->exec('SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ');
$pdo->exec('SET autocommit = 1');
} else {
$pdo->exec("SET SESSION transaction_isolation = 'REPEATABLE-READ', SESSION autocommit = 1");
}
} catch (Throwable $e) {
error_log('⚠️ Could not normalize fresh MySQL session: '.$e->getMessage());
}
Expand Down Expand Up @@ -539,8 +570,12 @@ protected function createConnection()
$connection->setPdo($fresh->getRawPdo())
->setReadPdo($fresh->getRawReadPdo());

// The server session is brand new, so restart the
// max_lifetime clock along with it.
// The server session is brand new: normalize it exactly
// like a created connection, and restart the max_lifetime
// clock along with it. Release no longer re-SETs session
// state, so this is the only thing keeping a reconnected
// session at the pool's isolation level.
$this->normalizeSession($connection);
$this->createdAt[spl_object_id($connection)] = microtime(true);
});
}
Expand Down Expand Up @@ -711,6 +746,11 @@ protected function longBorrowedCount(?float $now = null): int
));
}

/**
* When the last full prune pass ran, for the opportunistic-prune throttle.
*/
protected float $lastPruneAt = 0.0;

protected function startIdlePruner(): void
{
$heartbeat = (float) ($this->config['heartbeat'] ?? -1);
Expand Down
5 changes: 3 additions & 2 deletions src/Swoole/Handlers/OnServerStart.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ public function __construct(
protected $timerTable,
protected bool $shouldTick = true,
protected bool $shouldSetProcessName = true,
protected int|string|null $timeoutFallbackSignal = SIGTERM
protected int|string|null $timeoutFallbackSignal = SIGTERM,
protected int $timeoutSweepIntervalMs = 5000
) {
$this->timeoutFallbackSignal = EnsureRequestsDontExceedMaxExecutionTime::normalizeFallbackSignal($this->timeoutFallbackSignal);
}
Expand Down Expand Up @@ -59,7 +60,7 @@ public function __invoke($server)
}

if ($this->maxExecutionTime > 0) {
Timer::tick(1000, function () use ($server) {
Timer::tick($this->timeoutSweepIntervalMs, function () use ($server) {
(new EnsureRequestsDontExceedMaxExecutionTime(
$this->extension, $this->timerTable, $this->maxExecutionTime, $server, $this->timeoutFallbackSignal
))();
Expand Down
Loading
Loading