- https://microservices.io/patterns/data/saga.html
- https://learn.microsoft.com/ru-ru/azure/architecture/reference-architectures/saga/saga
- https://www.youtube.com/live/tLw8lJ-Eijk?si=6YIDa3lpvBYuGtFW
Если совсем коротко, то это согласованность действий и принцип "либо всё, либо ничего".
Дисклеймер: в данном примере умышленно утрируется понятие саги, по условиям задачи мы работаем в модульной системе, где каждый модуль это отдельный контекст, но сама система работает в рамках одного кластера.
Допустим, что у нас есть блок данных, который необходимо согласованно записать в N+1 модуль. Каждый модуль представляет свой ограниченный контекст, и работает в рамках отдельной базы данных (или просто в рамках различных, не связанных таблиц).
- Любое сложное действие можно разбить на несколько простых.
- Чем меньше будет размер "действия", тем больше будет над ним контроля (атомарность).
- Для любого атомарного действия, можно написать его анти-действие.
Решение заключается в том, чтобы разделить один набор действий на максимально простые, атомарные задачи, и для каждого шага выполнения (commit) написать его компенсирующее действие (rollback).
Получаем следующую схему:
- Любое действие можно представить как Транзакцию (transaction), которая состоит из Шагов (step).
- Каждый шаг умеет выполнять два действия: commit и rollback, и если выполнить сначала commit, а затем rollback, то состояние системы должно быть ровно таким же как если бы ничего не выполнялось (естественно с оговорками, например auto-increment).
- Все шаги выполняются последовательно (линейность в данном контексте сильный плюс), если какой-то шаг не выполнился, то вся транзакция не выполнилась.
- Соответственно у транзакции есть только два состояния: либо все шаги выполнились, либо ни один шаг не выполнился.
Дисклеймер: в данной реализации нет сохранения состояния транзакции, допускаем что мы работаем в рамках одного процесса.
Описываем транзакцию как набор шагов
final class TestTransaction implements TransactionInterface
{
public function steps(): StepCollection
{
return new StepCollection(
new Step(
OneStep::class,
[
'name' => 'one',
]
),
new Step(
TwoStep::class,
[
'name' => 'two',
]
),
new Step(
SaveStep::class,
),
);
}
}
Описываем шаги
final class OneStep extends TransactionStepBase
{
public function __construct(
public readonly string $name,
private readonly string $dateFormat = 'c',
) {
}
public function commit(): bool
{
// Полезная работа: запись в хранилище, в очередь...
$this->save(
new TestTransactionData(
name: $this->name,
datetime: $gmdate($this->dateFormat)
)
);
return true;
}
public function rollback(): bool
{
/** @var TestTransactionData $data */
$data = $this->current(); // получаем Состояние сохранённое при commit
// Полезная работа: удаление из хранилища, компенсационная задача в очередь
return true;
}
}
Инициируем экземпляр транзакции, и запускаем
/**
* @var TransactionRunner $transactionRunner
* @var TransactionResult $transaction
*/
$transaction = $transactionRunner->run(
new TestTransaction()
);
/**
* @var TransactionRunner $transactionRunner
* @var TransactionResult $transaction
*/
$transaction = $transactionRunner->run(
new TestTransaction()
);
/**
* @var TestTransactionData $testData данные, которые были записаны как модель TestTransactionData, в конечном шаге.
*/
$testData = $transaction->state->get(TestTransactionData::class);
https://github.com/kuaukutsu/yii2-component-demo
docker pull ghcr.io/kuaukutsu/php:8.1-cli
Container:
ghcr.io/kuaukutsu/php:${PHP_VERSION}-cli
(default)jakzal/phpqa:php${PHP_VERSION}
shell
docker run --init -it --rm -v "$(pwd):/app" -w /app ghcr.io/kuaukutsu/php:8.1-cli sh
The package is tested with PHPUnit. To run tests:
make phpunit
The code is statically analyzed with Psalm. To run static analysis:
make psalm
make phpcs
make rector