Skip to content
257 changes: 257 additions & 0 deletions docs/conventions/architecture/concurrency-control.md

Large diffs are not rendered by default.

29 changes: 23 additions & 6 deletions docs/conventions/architecture/events/transactions.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,14 @@ description: Правила работы с доменными событиям

## Общие правила

- События отправляются (dispatch) **после** `flush()`, когда данные уже записаны в БД.
- Транзакциями управляет код хендлера явно через `flush()`.
- События отправляются (dispatch) только после окончательной фиксации изменений в БД.
- Если транзакция не открыта заранее, Doctrine при вызове `flush()` сама открывает транзакцию,
записывает изменения и фиксирует их. После успешного `flush()` можно отправлять события.
- Если транзакция уже открыта, `flush()` только записывает изменения — их ещё можно откатить.
Для окончательной фиксации нужен `commit()` той транзакции, которая была открыта заранее;
события отправляются только после его успешного завершения.
- Выбор между условным обновлением и блокировкой, актуальность ORM и запрет внешнего ввода-вывода в транзакции описаны
в [управлении конкурентностью](../concurrency-control.md).


## Обзор
Expand All @@ -19,7 +25,11 @@ description: Правила работы с доменными событиям

## Ключевое правило

**События отправляются ПОСЛЕ `flush()`**, когда данные уже записаны в БД.
**События отправляются после окончательной фиксации транзакции**:
- после успешного `flush()`, если транзакция не была открыта заранее;
- после успешного `commit()` ранее открытой транзакции — одного `flush()` недостаточно.

Примеры и схема ниже предполагают, что транзакция не открыта заранее.

Данное правило зафиксировано в конвенции: [События (Event) — Требования к событиям](../../layers/application/event.md#требования-к-событиям).

Expand Down Expand Up @@ -107,6 +117,11 @@ flowchart TD
end
```

Ограничение схемы выше: сохранение только после ошибки брокера не закрывает остановку процесса между
фиксацией бизнес-данных и отправкой либо сохранением уведомления. Для гарантии доставки используйте
транзакционный `Outbox`: запись события в одной транзакции с бизнес-данными, отдельная отправка после фиксации.
Подробнее — [границы транзакций](../concurrency-control.md#границы-транзакций).

### Когда использовать шаблон `Outbox`

- Критически важные уведомления (например, live-updates статуса)
Expand Down Expand Up @@ -135,7 +150,8 @@ framework:
- doctrine_ping_connection
```

**Важно:** Middleware `doctrine_transaction` отсутствует — транзакциями управляет код хендлера явно через `flush()`.
**Важно:** Middleware `doctrine_transaction` отсутствует. В показанном сценарии обработчик вызывает `flush()`,
а Doctrine сама открывает и фиксирует транзакцию, поскольку она не была открыта заранее.

### Маршрутизация событий (Routing)

Expand All @@ -145,7 +161,7 @@ framework:

| Аспект | Реализация |
|--------|------------|
| **Порядок** | `flush()` → `dispatch()` |
| **Порядок** | `flush()` → `dispatch()`, если транзакция не открыта заранее; иначе `flush()` → `commit()` ранее открытой транзакции → `dispatch()` |
| **Транзакционность событий** | Нет (eventual consistency) |
| **Шаблон `Outbox`** | Опционально для критичных уведомлений |
| **Гарантия доставки** | Через `retry_strategy` компонента `Messenger` |
Expand All @@ -166,6 +182,7 @@ framework:

## Чек-лист для проведения ревью кода

- [ ] События отправляются после `flush()`, а не внутри транзакции.
- [ ] События отправляются после `flush()`, только если транзакция не открыта заранее; иначе — после её `commit()`.
- [ ] Соблюдена конвенция [управления конкурентностью](../concurrency-control.md).
- [ ] Транзакции управляются явно, без `doctrine_transaction` middleware.
- [ ] Стратегия повторов (retry strategy) настроена для критичных событий.
1 change: 1 addition & 0 deletions docs/conventions/architecture/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ description: Архитектурные документы: кросс-слой
## Документы

- [События и транзакции БД](events/transactions.md)
- [Управление конкурентностью](concurrency-control.md)
1 change: 1 addition & 0 deletions docs/conventions/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ description: Индекс всех конвенций проекта
- [Событие (Event)](layers/application/event.md)
- [Архитектура](architecture/index.md)
- [События и транзакции БД](architecture/events/transactions.md)
- [Управление конкурентностью](architecture/concurrency-control.md)
- [Слой Домена (Domain)](layers/domain.md)
- [Сущность (Entity)](layers/domain/entity.md)
- [Критерий (Criteria)](layers/domain/criteria.md)
Expand Down
47 changes: 44 additions & 3 deletions tests/Init/CodingStandardInitMakefileTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,20 +85,61 @@ public function testSuggestsManualWiringWhenMakefileIsMissing(): void
self::assertStringContainsString('make check', $output);
}

private function runInit(): string
public function testCopiesConcurrencyConventionAndRefreshesItsLinks(): void
{
$relativePaths = [
'architecture/concurrency-control.md',
'architecture/index.md',
'architecture/events/transactions.md',
'index.md',
];
$sourceDocs = dirname(__DIR__, 2) . '/docs/conventions';
$targetDocs = $this->directory . '/docs/conventions';

$this->runInit();

foreach ($relativePaths as $path) {
self::assertFileEquals($sourceDocs . '/' . $path, $targetDocs . '/' . $path);
}
self::assertStringContainsString(
'(concurrency-control.md)',
(string) file_get_contents($targetDocs . '/architecture/index.md'),
);
self::assertStringContainsString(
'(architecture/concurrency-control.md)',
(string) file_get_contents($targetDocs . '/index.md'),
);
self::assertStringContainsString(
'(../concurrency-control.md)',
(string) file_get_contents($targetDocs . '/architecture/events/transactions.md'),
);

unlink($targetDocs . '/architecture/concurrency-control.md');
file_put_contents($targetDocs . '/architecture/index.md', '# Old index');
file_put_contents($targetDocs . '/index.md', '# Old index');
$this->runInit(['--force']);
$this->runInit(['--force']);

foreach ($relativePaths as $path) {
self::assertFileEquals($sourceDocs . '/' . $path, $targetDocs . '/' . $path);
}
}

/** @param list<string> $arguments */
private function runInit(array $arguments = []): string
{
$command = [
PHP_BINARY,
dirname(__DIR__, 2) . '/bin/coding-standard-init',
$this->directory,
'--no-deptrac',
'--no-exceptions',
...$arguments,
];
$process = proc_open($command, [
0 => ['file', '/dev/null', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
], $pipes);
], $pipes, $this->directory);
self::assertIsResource($process);
$output = (string) stream_get_contents($pipes[1]);
$error = (string) stream_get_contents($pipes[2]);
Expand Down
Loading
Loading