diff --git a/docs/conventions/architecture/concurrency-control.md b/docs/conventions/architecture/concurrency-control.md new file mode 100644 index 00000000..a444c313 --- /dev/null +++ b/docs/conventions/architecture/concurrency-control.md @@ -0,0 +1,257 @@ +--- +package: prikotov/coding-standard +name: Управление конкурентностью +type: rule +description: Выбор блокировок и условных записей, границы транзакций и проверка конкурентных изменений +--- + +# Управление конкурентностью (Concurrency Control) + +**Управление конкурентностью** — согласование одновременных изменений общего состояния без потери обновлений. +Основы: [Doctrine: транзакции и конкурентность](https://www.doctrine-project.org/projects/doctrine-orm/en/current/reference/transactions-and-concurrency.html). + +## Общие правила + +### Условное обновление или блокировка + +- **Условное обновление (compare-and-swap, CAS)** — «измени данные, только если они всё ещё соответствуют + ожидаемому состоянию». База данных проверяет условие и изменяет запись одним атомарным `UPDATE`: + между проверкой и изменением другой процесс не может вклиниться. При несовпадении запись не изменяется. +- **Блокировка (lock)** — «сначала получи право работать с ресурсом». Другой участник того же протокола + должен дождаться освобождения ресурса или отказаться от операции. Границы защиты зависят от вида блокировки. + +Например, два процесса прочитали документ версии **5**. Первый сохраняет изменения с условием «версия равна 5» +и повышает её до **6**. Второй сохраняет с тем же условием, но БД уже видит версию 6: обновление отклоняется, +чужие изменения не затираются. Это условное обновление с проверкой версии. + +Далее используем русское название «условное обновление»; сокращение CAS обозначает тот же приём. + +### Выбор механизма + +- Сначала определите инвариант: какие данные должны оставаться согласованными и кто может их менять. +- Выбирайте минимальный механизм, который защищает весь инвариант, а не только отдельный метод. +- Ограничения БД (`UNIQUE`, внешние ключи, `CHECK`) сохраняйте независимо от блокировок приложения. + +| Сценарий | Механизм | Условия корректности | +|---|---|---| +| Один атомарный переход состояния | Условное обновление (`UPDATE` с проверкой ожидаемого состояния) | Ожидаемое состояние в `WHERE`, проверка числа изменённых строк | +| Многошаговый сценарий с общим ресурсом | Блокировка фреймворка (framework lock), например Symfony Lock | Единый протокол всех участников; финальная проверка записи при потере блокировки или наличии сторонних писателей | +| Инвариант нескольких записей, не выражаемый одним условным обновлением | Короткая транзакция с доказанным протоколом блокировок БД либо подходящим уровнем изоляции | Защищены все участвующие записи; предусмотрен повтор после конфликта | +| Получение элемента очереди несколькими обработчиками | Явная блокировка БД (explicit database lock), `FOR UPDATE SKIP LOCKED` | Выбор и отметка владения в одной короткой транзакции; обработка после фиксации | + +Условный `UPDATE` тоже берёт блокировки БД и может ждать. Отказ от явного `FOR UPDATE` не означает отсутствие блокировок. + +### Условное обновление + +- В `WHERE` включайте уникальный идентификатор и все проверяемые предпосылки изменения. +- Если состояние может измениться и вернуться обратно, сравнивайте также постоянно возрастающую версию. + Например, статус прошёл путь «черновик → опубликован → черновик»: прежний статус вернулся, но данные уже другие. + Это проблема возврата к прежнему значению (ABA problem); проверка только статуса не обнаружит такое изменение. + Все участники, изменяющие данные, включая административные операции, обязаны увеличивать версию; + идентификаторы не переиспользуются. +- Проверяйте число изменённых строк (affected rows): `1` — успех; `0` — конфликт или исчезновение строки согласно контракту; + больше `1` — нарушение инварианта, ошибка и откат текущей транзакции. +- Не превращайте `0` автоматически в успех. Повторная доставка считается успешной только после проверки идентификатора + операции и её результата — это идемпотентность (idempotency), а не игнорирование конфликта. +- Условное обновление одной строки не защищает произвольный инвариант нескольких строк. Проверка `NOT EXISTS` сама по себе + не исключает конкурентную вставку; нужны ограничения БД или общий протокол сериализации. +- Семантику числа строк и изоляции проверяйте на целевой СУБД. Условие версии должно приводить к реальному изменению. + +### Блокировка приложения + +- Используйте [Symfony Lock](https://symfony.com/doc/8.0/lock.html) через внедрение зависимостей. +- Для одного инварианта задайте один канонический ключ (canonical key): область, идентификатор арендатора при необходимости, + нормализованный идентификатор ресурса. Публикация, удаление и очистка этого ресурса используют тот же ключ. +- Все узлы используют общее хранилище блокировок (backend). Локальная файловая блокировка не координирует разные узлы. +- Выбирайте хранилище по топологии и гарантиям отказоустойчивости; универсального хранилища по умолчанию нет. +- Захватывайте блокировку до открытия транзакции БД. При `acquire() === false` или исключении не выполняйте защищённую + операцию — закрытый отказ (fail-closed). Освобождайте захваченную блокировку в `finally`. +- Документируйте срок действия блокировки (time to live, TTL): время, после которого она может истечь + даже без явного освобождения. Укажите бюджет выполнения и частоту продления (`refresh()`). + При невозможности продлить блокировку прекращайте работу; не продолжайте как её владелец. +- Ожидание ограничивайте: немедленный отказ либо ограниченные повторы с задержкой. Не используйте бесконечное ожидание + в запросе пользователя; конфликт и недоступность хранилища диагностируйте отдельно. +- Блокировка может истечь во время паузы процесса или запроса к БД. Проверка `isAcquired()` перед записью не устраняет эту гонку. + Финальное условное обновление обязательно, если блокировка может быть потеряна или данные меняют вне протокола. +- Блокировка Symfony не является маркером ограждения (fencing token) — возрастающим номером владения, + по которому защищаемый ресурс отвергает запросы прежнего владельца. Условное обновление защищает только проверяемую запись, + но не уже выполненный внешний эффект, например удаление файла. Для внешнего ресурса нужны поддерживаемый им маркер владения, + операция, безопасная при повторном выполнении, или отдельный протокол жизненного цикла. + +### Актуальность ORM + +- После ожидания перечитывайте состояние. Управляемая ORM-сущность (managed entity), загруженная ранее, может устареть. +- Повторный `find()` может вернуть объект из карты идентичности (identity map) без чтения БД. + Используйте явный `refresh()` либо отдельное скалярное чтение через DBAL; отсутствие строки обрабатывайте явно. +- Перечитывание не заменяет условное обновление: между чтением и записью состояние снова может измениться. +- Не начинайте транзакцию до ожидания: при повторяемом чтении (repeatable read) старый снимок БД + может остаться устаревшим даже после нового запроса. +- DBAL обходит ORM. После прямого `UPDATE` синхронизируйте затронутые сущности перед их использованием или `flush()`. + Не вызывайте глобальный `clear()` или `refresh()` вслепую: они могут отбросить несохранённые изменения. + +### Границы транзакций + +- Подготовку данных выполняйте до транзакции; после захвата блокировки заново проверяйте предпосылки подготовки. +- В транзакции БД запрещены HTTP/API, файловый и объектный ввод-вывод, запуск процессов, отправка в брокер + и ожидание другой долгой операции. Учитывайте также внешнюю транзакцию вызывающего кода и middleware. +- Транзакция охватывает только согласованные изменения БД. Задавайте ограничения времени ожидания блокировок + и выполнения запросов; откатывайте всю логическую операцию при ошибке. +- Если транзакция не открыта заранее, Doctrine при вызове `flush()` сама открывает транзакцию, + записывает изменения и фиксирует их. Если транзакция уже открыта, `flush()` только записывает изменения — + их ещё можно откатить. Для окончательной фиксации нужен `commit()` ранее открытой транзакции. + События отправляйте только после успешной фиксации: см. [события и транзакции](events/transactions.md). +- Для гарантированной доставки запись исходящего события сохраняется атомарно с бизнес-изменением + в той же БД (transactional outbox); доставка в брокер выполняется отдельно после фиксации. + Простой вызов брокера после `commit()` оставляет окно потери при остановке процесса. +- Внешняя очистка может оставаться под блокировкой приложения как обоснованное исключение жизненного цикла + (lifecycle exception), но не под открытой транзакцией БД. Опишите идемпотентность, повтор после сбоя + и защиту от удаления ресурса новым владельцем; одного срока действия блокировки недостаточно. + +### Порядок и исключения + +- Документируйте общий порядок захвата блокировок (lock ordering): от широкого ресурса к узкому; + для ресурсов одного уровня — по нормализованному идентификатору. Обратный порядок запрещён. +- Освобождайте вложенные блокировки в обратном порядке. Не пытайтесь получить широкую блокировку, + уже удерживая узкую; не приобретайте блокировку приложения под блокировками БД. +- Явные блокировки БД допустимы при обоснованной необходимости: например, получение элемента очереди + или защита нескольких строк в короткой транзакции. Укажите альтернативы, время ожидания и поведение при конфликте. +- Для [блокировок PostgreSQL](https://www.postgresql.org/docs/current/explicit-locking.html) задайте `lock_timeout` + и `statement_timeout` в рамках транзакции; `SKIP LOCKED` пропускает занятые строки, но не гарантирует отсутствие + всех ожиданий и справедливость очереди. См. [выборку с блокировкой](https://www.postgresql.org/docs/current/sql-select.html). +- При получении элемента очереди запишите владельца и срок обработки до `commit()`. Внешнюю работу выполняйте после; + завершение проверяет владельца, а зависшие элементы возвращаются по документированной политике. +- Взаимная блокировка (deadlock) и ошибка сериализации требуют отката и ограниченного повтора всей транзакции, + а не одного последнего SQL-запроса. + +## Зависимости + +- Домен описывает инварианты и контракты; он не зависит от Symfony Lock, DBAL или конкретного хранилища. +- Прикладной слой оркестрирует сценарий через контракты. Инфраструктура реализует блокировки и условные записи. +- Не создавайте универсальный компонент блокировок без повторяющейся потребности. + +## Расположение + +- Координация сценария — [Application](../layers/application.md), техническая реализация — [Infrastructure](../layers/infrastructure.md). +- Например: `{ProjectName}\Common\Module\{ModuleName}\Infrastructure\Service\{Name}Service`. +- Пример ниже — инфраструктурная операция, не [Command Handler](../layers/application/command-handler.md). + Контракт и преобразование технических исключений определяет вызывающий прикладной сценарий. + +## Как используем + +1. Зафиксируйте инвариант, участников и целевую СУБД с уровнем изоляции. +2. Выберите механизм по матрице; опишите ключ, порядок, сроки ожидания, потерю владения и повторы. +3. Подготовьте внешние данные, захватите блокировку при необходимости, перечитайте состояние и выполните условную запись. +4. Завершите транзакцию до внешних эффектов; освобождайте блокировки при любом исходе. +5. Проверьте протокол конкурентными тестами на реальных соединениях с целевой БД и выбранным хранилищем блокировок. + +## Пример + +Упрощённый сценарий поэтапной публикации: заранее подготовленный неизменяемый артефакт становится текущим через условное обновление указателя в БД. +Проектные сущности заменены нейтральной таблицей `publication`: `id` — первичный ключ, `version` — целое `NOT NULL`, +`artifact_key` — строка. Все изменения увеличивают версию. Артефакт подготовлен для переданной `expectedVersion`. + +Предпосылки: PHP 8.4, Symfony Lock 8.0, Doctrine DBAL 4, PostgreSQL с `READ COMMITTED` и автоматической фиксацией. +Соединение выделено для этой операции, без внешней транзакции и управляемых ORM-сущностей. +Внедрённая фабрика использует общее для узлов хранилище; таймауты SQL настроены на соединении. +Число `30` — пример срока действия блокировки в секундах, а не универсальная настройка. Продления нет: операция короткая, +а проверка версии в `UPDATE` защищает от устаревшей записи даже при истечении блокировки. +Для единственного `UPDATE` без других участников достаточно условного обновления; +блокировка показана для согласования с операциями жизненного цикла того же ресурса. + +```php +connection->isTransactionActive() || !$this->connection->isAutoCommit()) { + throw new \LogicException('Publication requires an autocommit connection without a transaction.'); + } + + $lock = $this->lockFactory->createLock('publication:' . $id, ttl: 30.0); + if (!$lock->acquire()) { + throw new \RuntimeException('Publication is busy.'); + } + + try { + $currentVersion = $this->connection->fetchOne( + 'SELECT version FROM publication WHERE id = :id', + ['id' => $id], + ['id' => ParameterType::INTEGER], + ); + if ($currentVersion === false || (int) $currentVersion !== $expectedVersion) { + return false; + } + + $affectedRows = $this->connection->executeStatement( + 'UPDATE publication SET artifact_key = :artifactKey, version = version + 1' + . ' WHERE id = :id AND version = :expectedVersion', + ['artifactKey' => $artifactKey, 'id' => $id, 'expectedVersion' => $expectedVersion], + [ + 'artifactKey' => ParameterType::STRING, + 'id' => ParameterType::INTEGER, + 'expectedVersion' => ParameterType::INTEGER, + ], + ); + if ($affectedRows > 1) { + throw new \LogicException('Publication uniqueness invariant violated.'); + } + + return $affectedRows === 1; + } finally { + $lock->release(); + } + } +} +``` + +Здесь повторное чтение выполнено через DBAL и не использует карту идентичности ORM. +В режиме автоматической фиксации PostgreSQL сама открывает и фиксирует транзакцию для этого `UPDATE`; +результат `>1` исключён первичным ключом. Для нескольких согласованных записей откройте короткую транзакцию +в коде и проверьте результат до её фиксации. +Пример не удаляет старый артефакт и не отправляет события: эти действия требуют отдельной политики жизненного цикла и доставки. +Ошибка после успешной записи, например при освобождении блокировки, не означает откат; повтор должен сверять результат операции. + +### Типичные ошибки + +| Ошибка | Исправление | +|---|---| +| Чтение и безусловная запись | Ожидаемые условия и версия в финальном `UPDATE` | +| HTTP под транзакцией БД | Подготовка до транзакции, повторная проверка перед записью | +| Повторный `find()` принят за обновление ORM | Явное перечитывание и обработка исчезнувшей строки | +| Разные ключи публикации и удаления | Один канонический ключ инварианта | +| Обратный порядок вложенных блокировок | Общий порядок уровней и идентификаторов | +| Срок действия блокировки принят за гарантию внешнего эффекта | Идемпотентность или проверяемый внешним ресурсом маркер владения | + +## Чек-лист для проведения ревью кода + +- [ ] Инвариант и все писатели перечислены; выбран минимальный достаточный механизм. +- [ ] Условное обновление проверяет ожидаемое состояние и версию при риске возврата к прежнему значению; + определены результаты `0`, `1`, `>1`. +- [ ] После ожидания состояние перечитано; исчезновение строки и синхронизация ORM после DBAL учтены. +- [ ] Ключ канонический, хранилище общее; определены срок действия, продление, время ожидания и закрытый отказ. +- [ ] Захват происходит до транзакции, освобождение — в `finally`; порядок вложенных блокировок един для всех участников. +- [ ] Внешний ввод-вывод не выполняется в транзакции; события отправляются после окончательного `commit()`. +- [ ] Исключения для очистки и явных блокировок обоснованы; условное обновление не объявлено защитой внешних эффектов. +- [ ] Повторы ограничены и идемпотентны; неопределённый результат после сбоя не считается автоматическим откатом. +- [ ] Два реальных соединения проверяют гонку одной версии: ровно один успех, второй — конфликт, без потерянного обновления. +- [ ] Проверены исчезновение строки после ожидания, отказ захвата, исключение внутри операции, истечение блокировки + и поздняя запись прежнего владельца; синхронизация теста использует барьеры, а не только `sleep()`. +- [ ] Для очереди проверены пропуск занятой строки, разные элементы у обработчиков и возврат просроченного владения. diff --git a/docs/conventions/architecture/events/transactions.md b/docs/conventions/architecture/events/transactions.md index 9cb9ed65..3a82886f 100644 --- a/docs/conventions/architecture/events/transactions.md +++ b/docs/conventions/architecture/events/transactions.md @@ -9,8 +9,14 @@ description: Правила работы с доменными событиям ## Общие правила -- События отправляются (dispatch) **после** `flush()`, когда данные уже записаны в БД. -- Транзакциями управляет код хендлера явно через `flush()`. +- События отправляются (dispatch) только после окончательной фиксации изменений в БД. +- Если транзакция не открыта заранее, Doctrine при вызове `flush()` сама открывает транзакцию, + записывает изменения и фиксирует их. После успешного `flush()` можно отправлять события. +- Если транзакция уже открыта, `flush()` только записывает изменения — их ещё можно откатить. + Для окончательной фиксации нужен `commit()` той транзакции, которая была открыта заранее; + события отправляются только после его успешного завершения. +- Выбор между условным обновлением и блокировкой, актуальность ORM и запрет внешнего ввода-вывода в транзакции описаны + в [управлении конкурентностью](../concurrency-control.md). ## Обзор @@ -19,7 +25,11 @@ description: Правила работы с доменными событиям ## Ключевое правило -**События отправляются ПОСЛЕ `flush()`**, когда данные уже записаны в БД. +**События отправляются после окончательной фиксации транзакции**: +- после успешного `flush()`, если транзакция не была открыта заранее; +- после успешного `commit()` ранее открытой транзакции — одного `flush()` недостаточно. + +Примеры и схема ниже предполагают, что транзакция не открыта заранее. Данное правило зафиксировано в конвенции: [События (Event) — Требования к событиям](../../layers/application/event.md#требования-к-событиям). @@ -107,6 +117,11 @@ flowchart TD end ``` +Ограничение схемы выше: сохранение только после ошибки брокера не закрывает остановку процесса между +фиксацией бизнес-данных и отправкой либо сохранением уведомления. Для гарантии доставки используйте +транзакционный `Outbox`: запись события в одной транзакции с бизнес-данными, отдельная отправка после фиксации. +Подробнее — [границы транзакций](../concurrency-control.md#границы-транзакций). + ### Когда использовать шаблон `Outbox` - Критически важные уведомления (например, live-updates статуса) @@ -135,7 +150,8 @@ framework: - doctrine_ping_connection ``` -**Важно:** Middleware `doctrine_transaction` отсутствует — транзакциями управляет код хендлера явно через `flush()`. +**Важно:** Middleware `doctrine_transaction` отсутствует. В показанном сценарии обработчик вызывает `flush()`, +а Doctrine сама открывает и фиксирует транзакцию, поскольку она не была открыта заранее. ### Маршрутизация событий (Routing) @@ -145,7 +161,7 @@ framework: | Аспект | Реализация | |--------|------------| -| **Порядок** | `flush()` → `dispatch()` | +| **Порядок** | `flush()` → `dispatch()`, если транзакция не открыта заранее; иначе `flush()` → `commit()` ранее открытой транзакции → `dispatch()` | | **Транзакционность событий** | Нет (eventual consistency) | | **Шаблон `Outbox`** | Опционально для критичных уведомлений | | **Гарантия доставки** | Через `retry_strategy` компонента `Messenger` | @@ -166,6 +182,7 @@ framework: ## Чек-лист для проведения ревью кода -- [ ] События отправляются после `flush()`, а не внутри транзакции. +- [ ] События отправляются после `flush()`, только если транзакция не открыта заранее; иначе — после её `commit()`. +- [ ] Соблюдена конвенция [управления конкурентностью](../concurrency-control.md). - [ ] Транзакции управляются явно, без `doctrine_transaction` middleware. - [ ] Стратегия повторов (retry strategy) настроена для критичных событий. diff --git a/docs/conventions/architecture/index.md b/docs/conventions/architecture/index.md index 5d1e0469..d5a589ca 100644 --- a/docs/conventions/architecture/index.md +++ b/docs/conventions/architecture/index.md @@ -12,3 +12,4 @@ description: Архитектурные документы: кросс-слой ## Документы - [События и транзакции БД](events/transactions.md) +- [Управление конкурентностью](concurrency-control.md) diff --git a/docs/conventions/index.md b/docs/conventions/index.md index 71ef2ef2..72ac917b 100644 --- a/docs/conventions/index.md +++ b/docs/conventions/index.md @@ -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) diff --git a/tests/Init/CodingStandardInitMakefileTest.php b/tests/Init/CodingStandardInitMakefileTest.php index 84cb8f90..71ab1508 100644 --- a/tests/Init/CodingStandardInitMakefileTest.php +++ b/tests/Init/CodingStandardInitMakefileTest.php @@ -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 $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]); diff --git a/todo/done/TASK-docs-concurrency-locking-convention.todo.md b/todo/done/TASK-docs-concurrency-locking-convention.todo.md new file mode 100644 index 00000000..7fffac0a --- /dev/null +++ b/todo/done/TASK-docs-concurrency-locking-convention.todo.md @@ -0,0 +1,157 @@ +--- +type: docs +created: 2026-09-09 15:25:30 (1788967530) +updated: 2026-09-10 +due: +started: 2026-09-10 02:31:02 (1789007462) +completed: 2026-09-10 03:18:49 (1789010329) +cancelled: +value: V2 +complexity: C2 +priority: P2 +cost_plan: +cost_fact: +depends_on: +epic: +author: Аналитик (pi) +assignee: Разработчик (pi) +branch: task/docs-concurrency-locking-convention +pr: https://github.com/prikotov/coding-standard/pull/124 +status: done +--- + +# TASK-docs-concurrency-locking-convention: Добавить общую конвенцию управления конкурентностью и блокировками + +## 0. Простое описание (Human Brief) + +### Проблема простыми словами (Problem) +- В общих конвенциях нет единого правила выбора между блокировкой приложения, блокировкой базы данных и условным обновлением. +- Команды разных проектов могут удерживать транзакцию во время долгого ввода-вывода, продолжать работу с устаревшими ORM-объектами или применять несовместимые ключи блокировок; это приводит к ожиданиям под нагрузкой, потерянным обновлениям и трудно воспроизводимым гонкам. + +### Варианты или путь решения (Solution Sketch) +- Добавить общую архитектурную конвенцию: когда обновлять данные только при совпадении ожидаемого состояния, а когда сначала получать блокировку приложения или базы данных. Объяснить условное обновление (compare-and-swap, CAS) на примере двух процессов, сохраняющих одну версию документа. +- Зафиксировать границы транзакций, перечитывание ORM-состояния после ожидания, порядок вложенных блокировок и обязательные concurrency tests (тесты конкурентности). + +### Ожидаемый результат (Expected Result) +- Разработчики и AI-агенты выбирают одинаковый механизм конкурентного доступа, не удерживают PostgreSQL-транзакции во время внешнего ввода-вывода и могут проверить решение по однозначному review checklist (чек-листу ревью). + +## 1. Концепция и Цель (Concept and Goal) + +### История (User Story) +> **User Story:** Как разработчик проекта-потребителя, я хочу иметь общую конвенцию управления конкурентными изменениями, чтобы предотвращать потерянные обновления и долгие блокировки без проектирования собственного протокола для каждого сценария. + +### Цель по SMART (Goal) +- В одной итерации добавить в `docs/conventions/architecture/` валидируемую конвенцию управления конкурентностью с рабочим PHP 8.4/Symfony 8.0 примером, связать её с индексами и существующей конвенцией транзакций и подтвердить полный документационный контур командой `composer check`. + +## 2. Контекст и Границы (Context and Scope) + +- **Где делаем:** новый документ `docs/conventions/architecture/concurrency-control.md`, индексы `docs/conventions/architecture/index.md` и `docs/conventions/index.md`; при необходимости — перекрёстная ссылка из `docs/conventions/architecture/events/transactions.md`. +- **Текущее поведение:** [конвенция событий и транзакций](../../docs/conventions/architecture/events/transactions.md) определяет порядок `flush()` и `dispatch()`, но не описывает выбор lock/CAS, поведение после ожидания блокировки и конкурентные проверки. +- **Границы (Out of Scope):** реализация универсального lock-компонента, PHPCS sniff (снифф), изменение прикладного кода проектов-потребителей, выбор конкретного production backend (хранилища production) для Symfony Lock, миграции БД и изменение публичных контрактов пакета. + +## 3. Требования, MoSCoW (Requirements) + +### 🔴 Обязательно (Must Have) +- [x] Добавить документ с YAML front matter (`name`, `type: rule`, `description`) и обязательной структурой конвенции из корневого `AGENTS.md`. +- [x] Дать матрицу выбора механизма: условный `UPDATE`/optimistic CAS для одного атомарного перехода; framework lock для многошагового инварианта; explicit database lock только при доказанной необходимости и с ограниченной транзакцией. +- [x] Для conditional write (условной записи) требовать ожидаемое состояние в `WHERE` и проверку affected rows: `1` — успех, `0` — проигранная гонка согласно контракту, больше `1` — нарушение инварианта. +- [x] Зафиксировать, что framework lock не заменяет финальный CAS, если lock может истечь, быть потерян или не охватывать внешнего писателя. +- [x] Требовать единый канонический ключ для всех операций одного инварианта, общий backend для всех узлов, освобождение в `finally`, fail-closed при неуспешном acquire и документированную политику TTL/refresh. +- [x] Требовать повторное чтение состояния после ожидания framework lock: ранее managed ORM entity (управляемая ORM-сущность) нельзя считать актуальной; отсутствие строки после ожидания обрабатывается явно. +- [x] Запретить HTTP/API, filesystem/object-storage I/O, запуск процессов, broker dispatch (отправку в брокер) и ожидание другой долгой операции внутри database transaction; lock acquire выполняется до открытия DB-транзакции. +- [x] Явно отличить framework lock от PostgreSQL locks: внешний cleanup может оставаться под framework lock только как обоснованное lifecycle exception (исключение жизненного цикла), но открытая DB-транзакция не должна охватывать этот I/O. +- [x] Описать порядок вложенных блокировок от широкого ресурса к узкому, запрет обратного порядка и необходимость документировать lock ordering (порядок блокировок). +- [x] Описать допустимые исключения для explicit database locks, например queue claiming через `FOR UPDATE SKIP LOCKED`: короткая транзакция, отсутствие внешнего I/O, timeout/skip policy и integration test (интеграционный тест) с реальным конкурентным соединением. +- [x] Добавить минимальный рабочий PHP 8.4/Symfony 8.0 пример с Symfony Lock, повторным чтением и DBAL conditional `UPDATE`; пример не должен быть псевдокодом. +- [x] Добавить review checklist: выбор механизма, affected rows, stale ORM state, canonical key, TTL, lock ordering, transaction boundary, idempotency и конкурентные тесты. +- [x] Добавить ссылки на новую конвенцию в оба индекса и связать её с конвенцией событий и транзакций. +- [x] Проверить, что `bin/coding-standard-init` переносит новый документ и обновлённые индексы в изолированный проект-потребитель без дополнительных ручных действий. + +### 🟡 Желательно (Should Have) +- [x] Добавить краткую таблицу типичных ошибок: read-then-write без CAS, I/O под DB-транзакцией, stale identity map (устаревшая карта идентичности), разные ключи одного инварианта и взаимная блокировка из-за обратного порядка. + +### 🟢 Опционально (Could Have) +- [ ] Добавить небольшую Mermaid sequence diagram (диаграмму последовательности) для потока `prepare outside transaction → acquire framework lock → reload → conditional update → commit → release`. + +### ⚫ Не будем делать (Won't Have) +- [ ] Вводить абсолютный запрет всех pessimistic locks (пессимистических блокировок) для всех СУБД и сценариев. +- [ ] Объявлять Memcached, Redis, PostgreSQL advisory locks или файловые блокировки универсальным backend по умолчанию. +- [ ] Гарантировать корректность только наличием framework lock без проверки ожидаемого состояния в финальной записи. +- [ ] Добавлять новую runtime dependency (зависимость времени выполнения), автоматический sniff или менять прикладные проекты в рамках этой задачи. +- [ ] Копировать в общую конвенцию проектные имена агрегатов, таблиц и lock keys (ключей блокировки), например `source_{uuid}`. + +## 4. План реализации (Implementation Plan) + +1. [x] Сопоставить новую конвенцию с текущими правилами событий, транзакций и [обработчиков команд](../../docs/conventions/layers/application/command-handler.md), исключив противоречия. +2. [x] Создать `docs/conventions/architecture/concurrency-control.md` с матрицей выбора, правилами, реальным PHP-примером и checklist. +3. [x] Обновить архитектурный и корневой индексы, добавить перекрёстную ссылку из документа о транзакциях. +4. [x] Проверить копирование документа через `coding-standard-init` в disposable fixture (одноразовую фикстуру) или существующий автоматический сценарий. +5. [x] Запустить валидацию документации и полный `composer check`. + +## 5. Критерии приёмки (Definition of Done) + +- [x] По конвенции можно однозначно выбрать между CAS, framework lock и explicit database lock как минимум для одношагового перехода, многошагового инварианта и queue claiming (получения элемента очереди). +- [x] Конвенция требует проверять affected rows и определяет семантику результатов `0`, `1` и `>1`. +- [x] Конвенция запрещает внешнее I/O внутри DB-транзакции и требует перечитывать ORM-состояние после ожидания framework lock. +- [x] Описаны canonical lock key, общий backend, TTL/refresh, `finally`, fail-closed acquire и порядок вложенных блокировок. +- [x] Рабочий PHP-пример проходит требования style/Markdown validators и демонстрирует финальный conditional write. +- [x] Новая страница доступна из `docs/conventions/index.md` и `docs/conventions/architecture/index.md`; внутренние ссылки валидны. +- [x] `coding-standard-init` переносит новую конвенцию и ссылки в проект-потребитель. +- [x] `composer check` и точечная валидация задачи проходят. + +## 6. Самопроверка (Verification) + +```bash +composer validate-docs +composer check +php vendor/bin/todo-md validate todo/TASK-docs-concurrency-locking-convention.todo.md +``` + +## 7. Риски и зависимости (Risks and Dependencies) + +- Слишком жёсткий запрет database locks может повредить очередям и другим сценариям, где `SKIP LOCKED` является корректным инструментом; поэтому конвенция задаёт критерии исключения, а не универсальный запрет. +- Symfony Lock не является fencing token (маркером ограждения): истечение TTL может допустить stale writer (устаревшего писателя), поэтому финальный optimistic CAS остаётся обязательным там, где состояние могло измениться. +- Семантика affected rows и уровни изоляции различаются между СУБД; пример должен явно обозначить поддерживаемые предпосылки и не выдавать PostgreSQL-специфичное поведение за переносимый стандарт. +- Приоритет `P2`, ценность `V2` и сложность `C2` выбраны как плановое кросс-проектное улучшение документации без изменения runtime-кода. + +## 8. Источники (Sources) + +- [Symfony Lock](https://symfony.com/doc/current/lock.html) +- [Doctrine ORM: Transactions and Concurrency](https://www.doctrine-project.org/projects/doctrine-orm/en/current/reference/transactions-and-concurrency.html) +- [PostgreSQL: Explicit Locking](https://www.postgresql.org/docs/current/explicit-locking.html) +- [События и транзакции БД](../../docs/conventions/architecture/events/transactions.md) +- [Обработчик команд](../../docs/conventions/layers/application/command-handler.md) + +## 9. Комментарии (Comments) + +- Инициирующий практический сценарий: staged publication (поэтапная публикация) в TasK потребовала заменить долгоживущие explicit row locks (явные блокировки строк) на framework coordination (координацию фреймворка) и финальный conditional update. + +### Проверка постановки и архитектуры + +- Формальная валидация: 0 ошибок и предупреждений. Human Brief, SMART, границы, проверяемые критерии и риски заполнены; `P2/V2/C2` соответствуют плановому улучшению документации. +- INVEST: требования многочисленны, но описывают один протокол и один документ; независимые изменения runtime-кода отсутствуют. Декомпозиция не требуется. +- Подтверждение исполнителя: исходный план понятен и выполнен. Новый документ не вводит универсальный компонент или зависимость. +- Уточнения после ревью: защита версией от возврата к прежнему значению, границы условного обновления одной строки, общий порядок одноуровневых ресурсов, ограниченные ожидания, потеря владения и идемпотентность внешних эффектов. +- Устранена неоднозначность `flush()`/`commit()` в связанной конвенции. Указано окно потери событий в существующей схеме сохранения Outbox только после ошибки брокера; полная переработка документа событий вне этой задачи. +- PHP-пример упрощён из `DoctrineSourceStagedPublicationService` проекта TasK: нейтральные имена, скалярное перечитывание DBAL вместо ORM, проверка версии и запрет внешней транзакции. Файлы TasK не изменялись. +- Для уже подключённого проекта обновление существующих индексов требует штатного `--force`; новый init-контракт не вводится. Проверены чистая установка, обновление и повторное обновление. + +### Результат и проверки + +- Добавлена [конвенция управления конкурентностью](../../docs/conventions/architecture/concurrency-control.md), обновлены оба индекса и ссылка из конвенции транзакций. +- Добавлен автоматический сценарий проекта-потребителя в `tests/Init/CodingStandardInitMakefileTest.php`, публичный init запускается из его корня. Тесты Init: 6 тестов, 55 проверок. +- `composer check` проходит: PHPUnit, сниффы, документация, внутренние ссылки, язык, задачи, PHPStan и PHPCS. +- PHP-пример извлечён из Markdown: `php -l` и PHPCS PSR-12 проходят. Дополнительно выполнены проверки с реальными Symfony Lock 8.0.14 и DBAL 4.4.3: успех, старая версия, отсутствующая строка, отказ захвата, запрет внешней транзакции, освобождение после SQL-ошибки. +- Проверка примера использовала SQLite и InMemoryStore; это не проверка конкурентности PostgreSQL или распределённого хранилища. Их обязательные сценарии сформулированы в чек-листе для проектов-потребителей; локальный PostgreSQL недоступен. +- `composer.json` не изменён; VCS-зависимости `git-workflow` и `todo-md` проверены через GitHub API, оба репозитория публичные. +- Опциональная Mermaid-диаграмма не добавлена: последовательность уже дана в разделе «Как используем». +- По замечанию пользователя улучшена понятность: условное обновление объяснено до матрицы на примере версий 5 и 6, сокращение CAS оставлено только при знакомстве с термином. Правила и чек-лист используют русское название; срок действия блокировки, возврат к прежнему значению и маркер владения объяснены простыми словами. Поведение PHP-примера не менялось; `composer check` повторно проходит. + +## История изменений (Change History) + +| Дата | Автор (роль) | Изменение | +| :--- | :--- | :--- | +| 2026-09-09 15:25:30 (1788967530) | Аналитик (pi) | Создание и детализация задачи на общую lock/CAS convention | +| 2026-09-10 | Разработчик (pi) | Проверка постановки и архитектуры, конвенция, согласование транзакций, тест установки и полный проверочный контур | +| 2026-09-10 | Разработчик (pi) | Замечание пользователя: объяснение условного обновления на примере, замена сокращений понятными названиями и пояснение смежных терминов | +| 2026-09-10 | Разработчик (pi) | По просьбе пользователя заменены термины неявной и явной транзакции объяснением поведения flush и commit; согласованы правила, резюме и чек-лист |