Пул з'єднань і транзакції
Auth, session, request: із цим ми розібралися в попередньому розділі, контекст плюс проксі, і фреймворк перестав плутати запити. База даних здається ще простішою: дев'ятий розділ основної серії вже розв'язав це питання, PDO Pool видає кожній корутині власне з'єднання і сам його забирає назад. Що тут може піти не так для Eloquent?
Може. Пул прозоро керує з'єднанням. Але він нічого не знає про ще один шматок стану, який Laravel тримає прямо поруч із цим з'єднанням: лічильник вкладеності транзакцій.
Де живе transactionLevel()
Усередині Illuminate\Database\Connection є звичайна властивість екземпляра:
protected $transactions = 0;beginTransaction() збільшує її, commit() і rollBack() зменшують. Поки одне Connection обслуговує один процес, у цьому є повний сенс: одна властивість, одна транзакція. Але PDO Pool працює на рівень нижче за Connection. Він підміняє фізичне з'єднання під об'єктом, тоді як сам об'єкт Connection, той самий, до якого прив'язана $this->transactions, лишається одним-єдиним екземпляром, спільним для всього DatabaseManager.
Відтворимо сценарій із попереднього розділу, цього разу з базою даних:
$server->addHttpHandler(function ($request, $response) {
DB::transaction(function () use ($request) {
Order::create(['user_id' => $request->getQueryParam('u')]);
delay(30); // корутина засинає прямо всередині транзакції
});
$response->json(['ok' => true]);
});Два запити конкурентно потрапляють у DB::transaction(). Обидва фізичних з'єднання пул чесно видає, кожне своє. Але $this->transactions для обох, це те саме число на тому самому об'єкті Connection. Перша корутина піднімає лічильник до 1, а потім засинає. Друга теж його піднімає, але вже до 2, хоча для неї це мала б бути зовнішня транзакція рівня 1. commit() у першій корутині спрацьовує проти неправильного рівня вкладеності, і Laravel мовчки видає SAVEPOINT там, де не мав би, або передчасно комітить транзакцію, яку сусідня корутина ще тримає відкритою.
Той самий рецепт, інша область: корутина, а не дерево запиту
У попередньому розділі стан auth жив у контексті області, тому що він спільний для всіх корутин одного запиту. Лічильник транзакцій влаштований інакше: PDO Pool видає фізичне з'єднання на корутину, а не на весь запит цілком (паралельна TaskGroup усередині обробника отримує з пулу два окремих з'єднання). Тож лічильник має жити в контексті корутини, а не в контексті області:
trait CoroutineTransactions
{
private const CTX_TRANSACTIONS = 'db.transactions';
public function transactionLevel()
{
if ($this->isAsyncMode()) {
return coroutine_context()->find(self::CTX_TRANSACTIONS) ?? 0;
}
return $this->transactions;
}
private function setTransactionLevel(int $level): void
{
if ($this->isAsyncMode()) {
coroutine_context()->set(self::CTX_TRANSACTIONS, $level, replace: true);
} else {
$this->transactions = $level;
}
}
// beginTransaction(), commit(), rollBack() і обробники помилок
// перевизначені так само: замість читання і запису
// $this->transactions вони йдуть через setTransactionLevel()/transactionLevel().
}coroutine_context() проти current_context(), це та сама межа, про яку йшлося в розділі Context основної серії: перший приватний для однієї корутини, другий спільний для всього дерева корутин запиту. Вибір тут не про стиль, він обов'язковий: переплутайте їх, і дві паралельні поїздки до бази даних усередині одного запиту знову почнуть ділити чужий лічильник транзакцій, діра просто переїде на поверх вище.
Трейт не переписує Connection цілком, він хірургічно перехоплює методи, що торкаються $this->transactions, і прив'язаний до конкретного класу з'єднання:
class AsyncPgsqlConnection extends PostgresConnection
{
use CoroutineTransactions;
}Для кожної DBMS є окремий клас, AsyncPgsqlConnection, AsyncMySqlConnection, AsyncMariaDbConnection, AsyncSqliteConnection, AsyncSqlServerConnection, бо батьківські класи Laravel уже відрізняються, тоді як трейт ізоляції лічильника один і той самий для всіх них.
Чому не можна просто загорнути в область увесь DatabaseManager
Спокуса є: якщо ми вже вміємо ховати сервіс за контекстом (ScopedServiceProxy з попереднього розділу), чому б не зробити те саме для db? Причина, чому цього не роблять, доволі неприємна. DatabaseServiceProvider::boot() один раз під час старту пише таке:
Model::setConnectionResolver($app['db']);Це статична властивість на самому класі Model, спільна для всіх моделей і всіх запитів. Якби db резолвився по-різному для кожної області, це статичне посилання й далі вказувало б на DatabaseManager того запиту, який створив його першим. Щойно область цього запиту завершується й прибирає за собою, об'єкт, на який вказує Model::$resolver, потрапляє під збирання сміття, а статична властивість лишається вказувати на мертву пам'ять. Результатом стають не "неправильні дані", а падіння всього процесу.
Тож db лишається синглтоном, як і завжди. Ізоляція фізичного з'єднання, це справа PDO Pool на рівні C, а не контейнера залежностей. Ізоляція лічильника транзакцій, справа трейту на рівні однієї корутини. Два вузьких, точних інструменти замість одного великого рефакторингу, який до того ж поклав би сервер.
Ми розібралися зі станом запиту і станом транзакції. Звичайні HTTP-відповіді Laravel обробляє сам, буферизуючи їх цілком. Але що робити, якщо відповідь не одноразова, а потокова: індикатор прогресу в браузер або виклик від сусіднього сервісу через gRPC? Саме туди ми й прямуємо в наступному розділі.