From 7701a28df1c2e194eb43252c4f497604e739f815 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sat, 25 Jul 2026 18:56:30 +0300 Subject: [PATCH] =?UTF-8?q?db-identifiers=20=D0=B8=20ansible/app-directori?= =?UTF-8?q?es=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BF=D0=B8=D1=81=D0=B0=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=BD=D0=B0=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=8B=D0=B9=20=D1=8F=D0=B7=D1=8B=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 7 и 9 правил соответственно, у каждого модальность и обоснование - условие выбора первичного ключа стало правилом с таблицей веток, на которые можно ссылаться из отступлений и механизации --- arch/db-identifiers.md | 167 ++++++++++++++++++++----------- stack/ansible/app-directories.md | 134 +++++++++++++++++-------- 2 files changed, 203 insertions(+), 98 deletions(-) diff --git a/arch/db-identifiers.md b/arch/db-identifiers.md index 8d4062c..a90bd21 100644 --- a/arch/db-identifiers.md +++ b/arch/db-identifiers.md @@ -1,79 +1,130 @@ ---- -status: рекомендуемая ---- - # Идентификаторы сущностей -Как выбираются и как выглядят первичные ключи. Схема БД меняется тяжело, -поэтому конвенция применяется **к новым таблицам**; существующие живут как -есть и перечислены в отступлениях. +Как выбираются и как выглядят первичные ключи сущностей. Форма записи — +`common/language.md`. -## Условие применимости +## Область действия -Вопрос задаётся **один раз на репозиторий**, а не по таблицам: +Схема базы меняется тяжело: таблица не переезжает от того, что её +потрогали. Поэтому правила распространяются на **новые таблицы**; +существующие живут как есть и перечисляются в отступлениях, причём этот +список постоянный, а не список задач на дочистку. -> Есть ли в приложении хотя бы одна сущность, которую адресуют извне — по -> id из URL, запроса API или callback-данных? -> -> - **Да** → весь репозиторий на сортируемый строковый id, который -> генерирует приложение (ULID), включая внутренние таблицы. -> - **Ни одной** → автоинкремент, и этого достаточно. +## Правила -Критерий — именно **адресация**: снаружи по этому id возвращаются к -системе. Не «id виден в логе» — туда рано или поздно попадает любой -идентификатор, и по такому критерию вторая ветка была бы недостижима. +### R1. Вид первичного ключа выбирается один раз на репозиторий -Почему квантор репозиторный, а не потабличный: внутренние сущности имеют -привычку становиться внешними, и тогда целочисленный id утекает в URL -задним числом. Плюс одна ментальная модель дешевле, чем спор при заведении -каждой таблицы. +**ДОЛЖЕН.** Репозиторий отвечает на один вопрос и держит ответ для всех +своих таблиц: -**Что не является смешиванием.** Запрет касается двух видов -*сгенерированных суррогатных* ключей в одной базе. Естественные и составные -ключи у таблиц-деталей — третья категория, они допустимы всегда (см. ниже). +> Есть ли в приложении хотя бы одна сущность, которую адресуют **извне** — +> по идентификатору из URL, запроса API или callback-данных? + +| № | Ответ | Вид ключа | +|---|---|---| +| R1.1 | да, хотя бы одна | сортируемый строковый идентификатор, который генерирует приложение (ULID) — во **всех** таблицах, включая внутренние | +| R1.2 | ни одной | автоинкремент | + +**Почему.** Квантор репозиторный, а не потабличный, по двум причинам. +Внутренние сущности имеют привычку становиться внешними — и тогда +целочисленный идентификатор утекает в URL задним числом, а миграция ключа +на живых данных стоит несопоставимо дороже, чем взять строковый сразу. +Вторая причина дешевле, но важнее в быту: одна ментальная модель избавляет +от спора при заведении каждой таблицы. + +Критерий — именно **адресация**: снаружи по этому идентификатору +возвращаются к системе. Не «виден в логе»: туда рано или поздно попадает +любой идентификатор, и по такому критерию ветка R1.2 была бы недостижима. + +Запрет смешивания касается двух видов **сгенерированных суррогатных** +ключей. Естественные и составные ключи у таблиц-деталей (R6) — третья +категория, они допустимы при любом ответе. -## Если ULID +### R2. При выборе R1.1 идентификатор генерирует приложение, а не база -- **PK — TEXT ULID** (26 символов Crockford base32), генерируется - **приложением** в момент создания записи, а не БД. -- Почему не UUID: UUIDv4 не сортируется по времени. UUIDv7 (RFC 9562) - сортируется, и против него остаются два довода — 36 символов против 26 и - дефисы: без них `grep` и двойной клик берут id целиком. -- Сортировка по времени создания даёт `ORDER BY id` = хронология с - точностью до миллисекунды. Внутри одной миллисекунды порядок произволен, - если генератор не монотонный, — на хронологию событий это не влияет. -- Глобальная уникальность across таблиц даёт побочный, но важный эффект: - голый `grep` по id находит все записи сущности независимо от имени поля. -- **Единая точка генерации и разбора.** Один модуль генерирует id и один - разбирает; самодельных генераторов по коду нет. +**ДОЛЖЕН.** Значение ключа известно до вставки строки. -## Канонический вид и границы +**Почему.** Идентификатор нужен раньше, чем база ответит: его пишут в лог +начатой операции, кладут в связанные записи одной транзакции и возвращают +клиенту. Генерация на стороне базы вынуждает либо ждать `last_insert_rowid` +и достраивать связи вторым проходом, либо иметь два источника истины о +моменте создания. -- Генерим и храним id в **нижнем регистре**. Сравнение строк в БД обычно - побайтовое, поэтому регистр — не косметика, а корректность. Спецификация - ULID канонизирует верхний регистр, и библиотеки по умолчанию отдают - именно его — нижний обеспечивает единая точка генерации, поэтому звать - библиотеку мимо неё нельзя. -- Любой пришедший снаружи id **обязательно** проходит разбор до запроса к - БД: он валидирует формат и нормализует регистр. -- Синтаксически невалидный id, которым **адресуют ресурс**, трактуем как - несуществующую сущность (404), **без похода в БД**: это и дешевле, и - убирает целый класс запросов с мусором. Невалидный id, пришедший из - собственной формы или кнопки, — не «не найдено», а некорректный ввод: там - это признак устаревшего интерфейса или бага, и маскировать его под 404 - значит терять диагностику. +### R3. Генерация и разбор идентификаторов — в единственной точке -## Естественные и составные ключи — для деталей +**ДОЛЖЕН.** Один модуль порождает идентификаторы, он же их разбирает. +Самодельных генераторов и парсеров в коде нет. -У таблиц-деталей и связей допустим естественный или составной ключ вместо -сгенерированного, когда он есть по природе данных. Отдельный id там — -мёртвый вес, который ещё и создаёт второй способ адресовать ту же строку. +**Почему.** Нормализация регистра (R4) и проверка формата обязаны +применяться ко всем идентификаторам без исключения. Любая вторая точка +входа рано или поздно окажется той, где нормализацию забыли, — и дефект +проявится не там, где создан. -Прочие генерируемые идентификаторы (батчи, задания, корреляционные ключи) — -через ту же единую точку: единый формат, сортируемость, корреляция в логах. +### R4. Канонический вид — нижний регистр + +**ДОЛЖЕН.** Идентификаторы порождаются и хранятся в нижнем регистре. + +**Почему.** Сравнение строк в базе обычно побайтовое, поэтому регистр — не +косметика, а корректность поиска. Спецификация ULID канонизирует **верхний** +регистр, и библиотеки по умолчанию отдают именно его: без единой точки (R3) +разный регистр появится в базе сам собой. + +### R5. Внешний идентификатор разбирается до обращения к базе + +**ДОЛЖЕН.** Значение, пришедшее снаружи, проходит разбор и нормализацию +раньше, чем по нему делается запрос. Реакция на неудачный разбор зависит от +источника: + +| № | Откуда пришёл | Разбор не удался → | +|---|---|---| +| R5.1 | путь или query URL | «не найдено» без обращения к хранилищу | +| R5.2 | собственная форма, данные кнопки | «некорректный ввод» либо «элемент устарел» | + +**Почему.** Синтаксически невалидное значение не может соответствовать +записи, поэтому поход в базу за ним — заведомо холостой; отсекая его на +границе, мы дёшево снимаем целый класс мусорного трафика. + +Разделение R5.1 и R5.2 нужно, потому что источники значат разное. Мусор в +URL — это чужая или протухшая ссылка, и «не найдено» описывает ситуацию +точно. Мусор из собственной формы — это баг интерфейса или устаревший +экран; ответ «не найдено» здесь скрывает дефект и лишает диагностики +единственный момент, когда он заметен. + +### R6. У таблиц-деталей допустим естественный или составной ключ + +**ДОПУСКАЕТСЯ.** Когда ключ есть по природе данных, отдельный +сгенерированный идентификатор не заводится. + +**Почему.** Суррогат поверх естественного ключа создаёт второй способ +адресовать ту же строку — а значит, возможность рассинхрона между ними и +лишний вопрос «по какому из них искать» на каждом запросе. Дополнительной +информации он не несёт. + +### R7. Прочие генерируемые идентификаторы — через ту же точку + +**ДОЛЖЕН.** Идентификаторы, не являющиеся первичными ключами (батч, +задание, корреляционный ключ), порождаются тем же модулем (R3) и в том же +формате. + +**Почему.** Единый формат делает работающим главный побочный эффект +строковых идентификаторов: `grep` по голому значению собирает все +упоминания сущности в логах независимо от имени поля. Второй формат +идентификаторов эту возможность отменяет ровно для тех записей, где она +чаще всего нужна. + +## Почему ULID, а не UUID + +Ветка R1.1 требует **сортируемый** строковый идентификатор. UUIDv4 не +сортируется по времени вовсе. UUIDv7 (RFC 9562) сортируется — и против него +остаются два довода: 36 символов против 26 и дефисы, из-за которых +идентификатор не берётся ни двойным кликом, ни `grep`-ом как одно слово. + +Сортировка даёт `ORDER BY id` = хронология с точностью до миллисекунды; +внутри одной миллисекунды порядок произволен, если генератор не монотонный, +— на порядок событий это не влияет. diff --git a/stack/ansible/app-directories.md b/stack/ansible/app-directories.md index 8f609ca..ab43607 100644 --- a/stack/ansible/app-directories.md +++ b/stack/ansible/app-directories.md @@ -1,63 +1,117 @@ --- -status: рекомендуемая extends: arch/app-directories.md --- # Категории директорий: реализация в Ansible -Как `arch/app-directories.md` раскладывается на сервере плейбуком. +Как категории из `arch/app-directories.md` раскладываются на сервере +плейбуком. Форма записи — `common/language.md`. -## Переменные и создание +## Область действия -- Директория объявляется переменной плейбука внутри `base_dir`, имя - переменной оканчивается на `_dir`. Для случая «одна директория на - категорию» это `config_dir`, `data_dir`, `cache_dir`; когда категория - состоит из нескольких, имя даётся по содержимому (`media_dir`, - `uploads_dir`, `dumps_dir`), а категория читается из списка бэкапа. -- Директории создаются **одной задачей циклом по списку**: список и есть - декларация того, что приложение пишет на диск. Разнесение по нескольким - задачам прячет эту декларацию. -- Владелец — пользователь, от имени которого работает приложение. Модель - выбирается на репозиторий: выделенный пользователь на приложение - (`app_owner_uid == app_owner_gid`) или общий `primary_user`. Какая модель - принята — фиксируется ниже. +Раскладка меняется вместе с миграцией данных, поэтому правила +распространяются на **новые приложения**; существующие переезжают по мере +касания, отдельной кампанией не переписываются. + +## Правила + +### R1. Каждая директория объявлена переменной `*_dir` + +**ДОЛЖЕН.** Директория приложения объявляется переменной плейбука внутри +`base_dir`, имя оканчивается на `_dir`. Для случая «одна директория на +категорию» это `config_dir`, `data_dir`, `cache_dir`; когда категория +состоит из нескольких директорий, имя даётся по содержимому (`media_dir`, +`uploads_dir`, `dumps_dir`). + +**Почему.** Переменная — единственная ссылка, которую разделяют задача +создания директории и список бэкапа (R4). Литерал пути в одном из этих мест +означает, что переименование директории молча разойдётся с бэкапом, и +обнаружится это при восстановлении. + +### R2. Директории создаются одной задачей циклом по списку + +**СЛЕДУЕТ.** Список директорий в единственной задаче создания. + +**Почему.** Этот список — единственное место, где декларировано всё, что +приложение пишет на диск. Разнесённое по нескольким задачам создание +отвечает на вопрос «какие директории есть у приложения» только чтением +всего плейбука, а именно этот вопрос задают при заведении бэкапа и при +разборе места на диске. + +### R3. Владелец директорий — пользователь, от имени которого работает приложение + +**ДОЛЖЕН.** Конкретная модель — выделенный пользователь на приложение +(`app_owner_uid == app_owner_gid`) или общий `primary_user` — выбирается на +репозиторий и фиксируется ниже. + +**Почему.** Правило про соответствие владельца рантайму, а не про +конкретную модель: приложение в контейнере пишет от определённого uid, и +если директория принадлежит другому, отказ произойдёт не при деплое, а при +первой записи — то есть после того, как плейбук отчитался об успехе. Выбор +же модели — свойство репозитория: сервер с одним пользователем и сервер с +изоляцией по приложениям решают разные задачи, и навязывать одну модель +обоим значит гарантировать вечное отступление. -## Список бэкапа +### R4. Список бэкапа собирается из тех же переменных -Плейбук кладёт в `base_dir` файл `backup-targets` — его читает оркестратор -бэкапов. Строки списка собираются из **тех же** переменных `*_dir`, что и -задача создания директорий: тогда переименование или перенос директории не -может разойтись с бэкапом. +**ДОЛЖЕН.** Плейбук кладёт в `base_dir` файл `backup-targets`, строки +которого ссылаются на переменные `*_dir` из R1, а не на литеральные пути. -В список идут директории категории «данные», включая директорию дампов, и -не идут конфигурация и кеш. +**Почему.** Правило вывода списка механическое (R5), но применяет его +человек или шаблон — то есть ошибиться можно. Общая переменная делает целый +класс ошибок невозможным: переименовал директорию — переименовалось в +обоих местах. Независимо набранный список расходится тихо и проявляется в +единственный момент, когда это уже неисправимо. -## Монтирование в контейнер +### R5. В список бэкапа идут только данные -- Конфигурация — `:ro`, где приложение это позволяет. Приложение, которое - переписывает свой конфиг, монтируется на запись — это отступление, и оно - записывается. -- Данные и кеш — на запись. -- `docker-compose.yml` остаётся в корне `base_dir`: туда смотрит - `project_src` модуля `docker_compose_v2`. +**ДОЛЖЕН.** Директории категории «данные», включая директорию дампов, — в +списке; конфигурация и кеш — нет. -## Секреты +**Почему.** Реализация правила базовой конвенции. Кеш раздувает снапшот без +пользы, а конфигурация содержит отрендеренные секреты — бэкап уезжает в +облако, и источником истины для секретов остаётся vault, а не снапшот. -Секреты приходят из vault-переменных и рендерятся шаблоном. Два способа, в -порядке предпочтения: +### R6. Конфигурация монтируется только на чтение -1. **В файл конфигурации** (роль `secrets`) — предпочтительный: секрет - лежит под `0600` у пользователя приложения, не наследуется дочерними - процессами и не виден в `docker inspect`. -2. **В `environment:` compose-файла** — когда приложение не умеет читать - секреты из файла. Задача рендера идёт с `no_log: true`. +**СЛЕДУЕТ.** В compose конфигурация подключается с `:ro`. -Второй способ — вынужденный: он кладёт секрет в метаданные контейнера и в -файл compose на диске. Приложение, умеющее файловые секреты, переводится на -первый способ при ближайшем касании. +**Почему.** Плейбук — источник истины для конфигурации, и `:ro` превращает +это из договорённости в свойство системы: приложение, которое втихую +переписывает свой конфиг, падает сразу, а не расходится с репозиторием +незаметно. Приложение, которому запись в конфиг нужна по устройству, +монтируется на запись — это отступление, и оно записывается. + +### R7. `docker-compose.yml` лежит в корне `base_dir` + +**ДОЛЖЕН.** Файл не переносится во вложенную директорию. + +**Почему.** Туда смотрит `project_src` модуля `docker_compose_v2`. Правило +внешнее по происхождению, но нарушается легко — при попытке «навести +порядок» и убрать compose в `config/`, где ему по смыслу категорий было бы +место. + +### R8. Секреты рендерятся в файл конфигурации + +**СЛЕДУЕТ.** Значения приходят из vault-переменных и попадают в файл, +принадлежащий пользователю приложения. + +**Почему.** Файл под `0600` не наследуется дочерними процессами, не виден в +`docker inspect` и не оседает в compose-файле на диске. Это те же три +довода, по которым базовая конвенция конфигурации выбирает файл вместо +окружения. + +### R9. Когда приложение не умеет файловые секреты — `environment` под `no_log` + +**ДОПУСКАЕТСЯ.** Задача рендера идёт с `no_log: true`. + +**Почему.** Явное разрешение нужно, чтобы R8 не читался как запрет на +деплой такого приложения. Способ вынужденный: секрет попадает в метаданные +контейнера и в compose-файл на диске. Приложение, научившееся читать +секреты из файла, переводится на R8 при ближайшем касании.