Задачи 025-030: ёмкость префиксов, политика входа, дерево префиксов
Повторный анализ кодовой базы (docs/reviews/2026-09-26-codebase-review-2.md) и доработки:
025 Ёмкость префикса — размер его подсети (а не сумма листьев); «Обзор» считает ёмкость
по корневым активным IPv4-префиксам и адреса внутри них.
026 Политика блокировки входа: 5 неудач на логин+IP, 20 на IP, 50 на логин со всех IP,
кроме известных IP (known_logins, миграция 0009) — владельца нельзя заблокировать анонимно.
027 Сериализация попыток входа по IP (advisory-lock после блокировки логина).
028 UI «Префиксы»: загрузка всех страниц (до 20 000), счётчики по total, предупреждение об усечении.
029 Advisory-lock по VRF для операций, меняющих дерево префиксов и раскладку адресов.
030 Исправление замечаний ревью 025-029: _lock_prefix (VRF блокируется до чтения префикса,
409 при одновременном переносе), константы политики входа перенесены в app/services.py.
Тесты: 14 passed (проверка ёмкости родителя приведена к семантике 025); сквозные сценарии
и гонки — docs/reviews/2026-09-26-changes-025-029-review.md, 2026-09-27-changes-030-review.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
13e17fbb47
commit
03d727e496
25 files changed
+877
-78
No files matched your search
@@ -0,0 +1,30 @@
|
||||
# Ёмкость и загрузка частично разбитого префикса (изменение 025)
|
||||
|
||||
Находка № 1 из `docs/reviews/2026-09-26-codebase-review-2.md`, серьёзность — средняя. Выполняется первым.
|
||||
|
||||
## Context
|
||||
`_capacities` (`app/api/v1/prefixes.py`) считает ёмкость родителя как сумму ёмкостей его листьев, а `used` (`_usage`) — по всем адресам поддерева,
|
||||
включая адреса самого родителя вне дочерних префиксов. После автовыделения подсетей (изменение 010) частичное разбиение стало обычным сценарием.
|
||||
Воспроизведено: `172.20.0.0/24` с 40 адресами → `40/254, 16 %`; после выделения одного `/30` → `40/2, 100 %`. Экран адресов того же префикса показывает ёмкость 254.
|
||||
«Обзор» (`app/api/v1/overview.py`) искажён той же причиной: `assigned` — все IPv4-адреса, `capacity` — сумма только листьев.
|
||||
|
||||
## Решение
|
||||
1. **Ёмкость префикса — размер его собственной подсети.** `capacity(str(prefix))` из `app/services.py` (IPv4 без адреса сети/broadcast для ≤ /30, ограничение `MAX_CAPACITY`).
|
||||
`_capacities` удалить; в `_prefix_outs` — `cap = capacity(str(r.prefix))`. `used` (`_usage`, по поддереву CIDR того же VRF) не меняется: дублей нет благодаря изменению 011.
|
||||
Так числа в списке префиксов совпадут с `summary.capacity` экрана адресов.
|
||||
2. **«Обзор»:**
|
||||
- «корни» — активные IPv4-префиксы, не содержащиеся ни в одном другом активном IPv4-префиксе того же VRF (по CIDR, не по `parent_id`);
|
||||
- `capacity` = сумма `capacity()` корней;
|
||||
- `assigned` / `reserved` = IPv4-адреса со статусом assigned/reserved, лежащие внутри какого-либо корня того же VRF (адреса в неактивных ветках не учитываются) — один SQL-запрос с `EXISTS`/`JOIN` по `address <<= root.prefix`;
|
||||
- `utilization` — через `utilization()`;
|
||||
- `top_prefixes` («Высокая загрузка») — как сейчас: листья IPv4 с `capacity > 1`, по убыванию загрузки (формула загрузки листа не меняется);
|
||||
- `prefixes`, `vrfs`, `recent_changes` — без изменений.
|
||||
3. Формат ответов API не меняется. UI не меняется.
|
||||
4. `README.md`, раздел «Модель данных»: строку «Ёмкость листового префикса — размер подсети…; родителя — сумма вложенных листьев» заменить новым правилом; описать расчёт «Обзора».
|
||||
|
||||
## Файлы
|
||||
`app/api/v1/prefixes.py`, `app/api/v1/overview.py`, `README.md`.
|
||||
|
||||
## Проверка
|
||||
- Сценарий из Context: после выделения `/30` ёмкость родителя остаётся 254, загрузка — 16 %.
|
||||
- «Обзор» на демо-данных: `capacity` равна сумме корней (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 и т.п. — только активные, IPv4), загрузка не больше 100 %.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Итог: ёмкость и загрузка частично разбитого префикса (изменение 025)
|
||||
|
||||
## Что сделано
|
||||
- `app/api/v1/prefixes.py`: функция `_capacities` (ёмкость родителя = сумма ёмкостей листьев) удалена; `_prefix_outs` считает ёмкость каждого префикса как `capacity(str(r.prefix))` —
|
||||
размер его собственной подсети, независимо от того, есть ли у него вложенные префиксы. `used` не менялся (по-прежнему `_usage`, по поддереву CIDR того же VRF). Неиспользуемый импорт `MAX_CAPACITY` убран.
|
||||
- `app/api/v1/overview.py` переписан:
|
||||
- «корни» (`_ipv4_roots`) — активные IPv4-префиксы, не вложенные ни в один другой активный IPv4-префикс того же VRF по CIDR (`NOT EXISTS` с оператором `>>`, не по `parent_id`);
|
||||
- `capacity` — сумма `capacity()` корней;
|
||||
- `assigned`/`reserved` — один SQL-запрос: `addresses` JOIN CTE корней по `vrf_id` и `address <<= root.prefix`, `COUNT(DISTINCT id)` по статусу, только IPv4;
|
||||
- `top_prefixes` («Высокая загрузка») не менялся — по-прежнему листовые IPv4-префиксы с ёмкостью > 1, по убыванию загрузки;
|
||||
- `prefixes`, `vrfs`, `recent_changes` не менялись.
|
||||
- `README.md`, раздел «Модель данных»: заменено правило ёмкости, описан расчёт «Обзора» по корням.
|
||||
|
||||
## Отклонения от плана
|
||||
Нет. План выполнен как описан.
|
||||
|
||||
## Как проверено
|
||||
1. `venv/bin/python -c 'import app.main'`, `node --check web/app.js` — без ошибок.
|
||||
2. Стенд `ipam_control_006` пересобран (`docker compose -p ipam_control_006 up -d --build app`), контейнер `healthy`.
|
||||
3. Сценарий из плана на временной организации (`rv3-cap-test`, скрипт в scratchpad, не в репозитории): `172.20.0.0/24` + 40 адресов → `used=40, capacity=254, utilization=16`;
|
||||
после `POST /prefixes/{id}/subnets/next {length:30}` — те же `capacity=254, utilization=16` у родителя (раньше падало до `capacity=2, utilization=100`). **PASS.**
|
||||
4. «Обзор»: результат API (`capacity=16843256`, `assigned=485` с учётом временных данных) сверен с прямым SQL-запросом той же логики (роли посчитаны вручную в psql) после удаления временной
|
||||
организации (`capacity=16843002`, `assigned=445`) — разница ровно 254/40, что соответствует временному корню `172.20.0.0/24`. **PASS.**
|
||||
5. Временная организация и все её объекты удалены по завершении проверки.
|
||||
|
||||
## Не проверено
|
||||
- Внешний вид в браузере (браузер недоступен в этой среде) — проверено только через прямые вызовы API и сверку с SQL.
|
||||
- Сценарий с несколькими VRF и IPv6-префиксами одновременно (на демо-данных стенда — только реальные данные, без специально подготовленного смешанного случая).
|
||||
@@ -0,0 +1,34 @@
|
||||
# Политика блокировки входа без блокировки администратора анонимом (изменение 026)
|
||||
|
||||
Находка № 2 из `docs/reviews/2026-09-26-codebase-review-2.md`, серьёзность — средняя.
|
||||
|
||||
## Context
|
||||
Лимит по логину (5 неудач за 10 минут, изменение 012) действует для всех IP, и во время блокировки отклоняется и верный пароль.
|
||||
Любой, кто знает логин (например, `admin`), может отправлять 5 неверных попыток раз в 10 минут и держать учётную запись заблокированной.
|
||||
|
||||
## Решение (`app/api/v1/auth.py`, `app/models.py`, миграция)
|
||||
1. **Три области лимита** (окно 10 минут, блокировка — окно после последней неудачи, как сейчас):
|
||||
| Область | Порог | Кого блокирует |
|
||||
|---|---|---|
|
||||
| логин + IP | 5 | этот логин с этого IP |
|
||||
| IP | 20 | любые логины с этого IP (как сейчас) |
|
||||
| логин (все IP) | 50 | этот логин со всех IP, **кроме «известных» IP** |
|
||||
Константы `MAX_PER_LOGIN_IP = 5`, `MAX_PER_IP = 20`, `MAX_PER_LOGIN = 50` в `auth.py`.
|
||||
2. **Известные IP:** таблица `known_logins (username, client_ip, last_seen)` с первичным ключом `(username, client_ip)` — новая миграция `0009` и модель `KnownLogin`.
|
||||
При успешном входе — upsert (`INSERT … ON CONFLICT (username, client_ip) DO UPDATE SET last_seen = now()`). IP считается известным, если `last_seen` не старше 30 дней (`KNOWN_IP_DAYS = 30`).
|
||||
Блокировка по области «логин» не применяется к запросам с известного IP. Области «логин + IP» и «IP» действуют всегда.
|
||||
Записи старше 30 дней удалять в часовом цикле ротации (`app/rotation.py`, рядом с очисткой `login_attempts`).
|
||||
3. `_retry_after` возвращает `(секунды, область, счётчики)`, где область — `login_ip` | `ip` | `login`, счётчики — по каждой области. Запись `session.failed` — по-прежнему только первая неудача
|
||||
этого логина в окне (счётчик по логину во всех IP). `session.locked` — один раз при первом пересечении порога любой областью, в `diff` — `scope` (`login_ip`/`ip`/`login`), `attempts`, `retry_after_seconds`,
|
||||
для `ip` — `distinct_logins` (как сейчас).
|
||||
4. Сериализацию попыток по логину (`pg_advisory_xact_lock(hashtext(name))`, изменение 024) и выравнивание времени ответа сохранить.
|
||||
5. Успешный вход удаляет неудачи этого логина (по всем IP), как сейчас.
|
||||
6. `README.md`, раздел «Журнал» (абзац «Вход»): новые пороги, известные IP, почему анонимный клиент не может заблокировать вход с рабочего места пользователя.
|
||||
|
||||
## Файлы
|
||||
`app/api/v1/auth.py`, `app/models.py`, `alembic/versions/0009_known_logins.py`, `app/rotation.py`, `README.md`.
|
||||
|
||||
## Проверка
|
||||
- 5 неверных попыток логина с IP A → 429 для A; верный пароль с IP B, с которого этот пользователь входил ранее, — 200.
|
||||
- 50 неверных попыток логина с разных IP (эмуляция через `TRUSTED_PROXIES` недоступна на стенде → проверка по коду или прямыми вставками в `login_attempts`) → вход с нового IP 429, с известного — 200.
|
||||
- `alembic check` без расхождений.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Итог: политика блокировки входа без блокировки администратора анонимом (изменение 026)
|
||||
|
||||
Выполнено вместе с изменением 027 (один файл `app/api/v1/auth.py`); отдельный SUMMARY для 027 — там же, с разницей только в advisory-lock по IP.
|
||||
|
||||
## Что сделано
|
||||
- `app/api/v1/auth.py`:
|
||||
- три области лимита за окно 10 минут: `login_ip` (5, эта пара логин+IP), `ip` (20, любые логины с этого IP — как раньше), `login` (50, этот логин со всех IP, **кроме известных**);
|
||||
константы `MAX_PER_LOGIN_IP`, `MAX_PER_IP`, `MAX_PER_LOGIN`;
|
||||
- `_is_known(db, login, ip)` — IP считается известным для логина, если есть строка в `known_logins` с `last_seen` не старше `KNOWN_IP_DAYS = 30`;
|
||||
- `_retry_after` переписана: считает счётчики по всем трём областям, блокировка возвращается для первой пересечённой, но `scope == "login"` пропускается (не блокирует), если `known=True`;
|
||||
`login_ip`/`ip` блокируют всегда;
|
||||
- `_remember_login(db, login, ip)` — upsert в `known_logins` (`INSERT … ON CONFLICT (username, client_ip) DO UPDATE SET last_seen = now()`, через `sqlalchemy.dialects.postgresql.insert`),
|
||||
вызывается при успешном входе;
|
||||
- `session.locked`: `diff.scope` теперь одно из `login_ip`/`ip`/`login` (а не только `login`/`ip`, как в изменении 024), сообщение в журнале — по-русски описывает область.
|
||||
- `app/models.py`: модель `KnownLogin` (`username`, `client_ip` — составной первичный ключ; `last_seen`).
|
||||
- `alembic/versions/0009_known_logins.py`: таблица `known_logins`.
|
||||
- `app/rotation.py`: `known_logins` с `last_seen` старше `KNOWN_IP_DAYS` удаляются вместе с остальной ротацией (импорт `KNOWN_IP_DAYS` из `auth.py`).
|
||||
- `README.md`, раздел «Журнал» → абзац «Вход»: описаны три области, известные IP и защита администратора от анонимной блокировки.
|
||||
|
||||
## Отклонения от плана
|
||||
Нет. Пороги и пространства имён — как в плане (значения из примера плана: `MAX_PER_LOGIN_IP=5`, `MAX_PER_IP=20`, `MAX_PER_LOGIN=50`, `KNOWN_IP_DAYS=30`).
|
||||
|
||||
## Как проверено
|
||||
1. `venv/bin/python -c 'import app.main'` — без ошибок (нет циклического импорта `rotation.py` → `auth.py`, так как `auth.py` не импортирует `rotation`).
|
||||
2. Стенд пересобран, `healthy`; миграция `0009` применена (`alembic_version = 0009`); `alembic check` — без расхождений.
|
||||
3. Сценарий из плана (временный пользователь `rv3-locka`, скрипт в scratchpad):
|
||||
- 5 неверных попыток логина с одного IP (докер-шлюз, виден серверу как `172.31.0.1`) → 4×401, 5-я попытка сама пересекает порог `login_ip` и получает 429 (как и раньше для одиночного порога);
|
||||
верный пароль с того же IP сразу после — тоже 429 (область `login_ip` активна);
|
||||
- верный пароль с **другого** реального IP (127.0.0.1, изнутри контейнера приложения) для того же логина — **200**: анонимный клиент с одного IP не блокирует пользователя с другого. **PASS.**
|
||||
4. Сценарий «перебор с разных IP» (план: «эмуляция через `TRUSTED_PROXIES` недоступна на стенде → проверка … прямыми вставками в `login_attempts`»), временный пользователь `rv3-lockb`:
|
||||
- успешный вход с IP A (докер-шлюз) регистрирует его как известный;
|
||||
- 55 неудачных попыток вставлены напрямую в `login_attempts` с 55 разными фиктивными IP (`203.0.113.1..55`) для этого логина;
|
||||
- вход с верным паролем с нового, никогда не виденного IP (127.0.0.1 изнутри контейнера) → **429** (область `login`, IP не известен);
|
||||
- вход с верным паролем с известного IP A → **200** (область `login` не применяется к известному IP; счётчик пересобирается заново после того, как предыдущая проверка его не сбросила). **PASS.**
|
||||
5. `login_attempts` очищена после проверок (`delete from login_attempts`). Временные пользователи `rv3-locka`/`rv3-lockb` удалены. Осталась одна легитимная строка `known_logins`
|
||||
(`admin`, IP докер-шлюза) — образовалась от обычных входов администратора при тестировании, не «rv3-» тестовые данные, не удалялась.
|
||||
|
||||
## Не проверено
|
||||
- Реальный перебор с физически разных IP-адресов (сеть стенда не даёт больше двух различимых реальных адресов) — область `login` проверена вставкой записей напрямую в `login_attempts`,
|
||||
как и предусмотрено планом.
|
||||
- Поведение при `TRUSTED_PROXIES`, настроенном на реальный прокси (не задан на стенде).
|
||||
@@ -0,0 +1,21 @@
|
||||
# Сериализация попыток входа по IP (изменение 027)
|
||||
|
||||
Находка № 7 из `docs/reviews/2026-09-26-codebase-review-2.md`, серьёзность — низкая. Выполняется вместе с 026 (тот же файл).
|
||||
|
||||
## Context
|
||||
Изменение 024 сериализует попытки одного логина (`pg_advisory_xact_lock(hashtext(логин))`). Параллельные попытки **разных** логинов с одного IP проходят проверку лимита по IP
|
||||
одновременно, и порог 20 можно превысить на степень параллелизма.
|
||||
|
||||
## Решение (`app/api/v1/auth.py`, `login`)
|
||||
1. После блокировки по логину и до `_retry_after` — вторая блокировка `pg_advisory_xact_lock(hashtext('ip:' || ip))`, если `ip` известен.
|
||||
Порядок всегда «логин, затем IP»: IP-блокировка берётся последней, поэтому цикл ожидания невозможен.
|
||||
2. Использовать двухаргументную форму advisory-lock с отдельными пространствами ключей, чтобы исключить коллизии между логинами и IP:
|
||||
`pg_advisory_xact_lock(<LOGIN_NS>, hashtext(name))` и `pg_advisory_xact_lock(<IP_NS>, hashtext(ip))`; константы пространств — в `auth.py` (например, 7031 и 7032).
|
||||
3. Комментарий в коде: цена — попытки с одного IP (в том числе за NAT) обрабатываются по одной, время ответа при массовом входе растёт на время argon2.
|
||||
|
||||
## Файлы
|
||||
`app/api/v1/auth.py`, `README.md` (одна фраза в абзаце «Вход»).
|
||||
|
||||
## Проверка
|
||||
16 параллельных неверных входов на 16 разных несуществующих логинов с одного IP при заранее вставленных 15 неудачах этого IP → проверено не больше 5 паролей
|
||||
(в `login_attempts` добавилось ≤ 5 записей), остальные — 429.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Итог: сериализация попыток входа по IP (изменение 027)
|
||||
|
||||
Выполнено вместе с изменением 026 (один файл `app/api/v1/auth.py`, один проход); детали трёх областей лимита — в SUMMARY 026.
|
||||
|
||||
## Что сделано
|
||||
- `app/api/v1/auth.py`, `login`: после advisory-lock по логину (`pg_advisory_xact_lock(LOGIN_LOCK_NS, hashtext(name))`, изменение 024) и до `_retry_after` — вторая блокировка по IP,
|
||||
если `ip` известен: `pg_advisory_xact_lock(IP_LOCK_NS, hashtext(ip))`. Порядок всегда «логин, затем IP» — исключает взаимную блокировку.
|
||||
- Использована двухаргументная форма advisory-lock с раздельными пространствами ключей (`LOGIN_LOCK_NS = 7031`, `IP_LOCK_NS = 7032`), чтобы `hashtext(логин)` и `hashtext(ip)`
|
||||
гарантированно не пересекались (не зависит от совпадения хэшей, как было бы при одноаргументной форме с общим пространством).
|
||||
- Комментарий в коде — плата за сериализацию: попытки с одного IP (в том числе за NAT) обрабатываются по одной, время ответа при массовом переборе растёт на время проверки argon2.
|
||||
- `README.md`, абзац «Вход»: добавлено предложение о второй сериализации и её цене.
|
||||
|
||||
## Отклонения от плана
|
||||
Нет.
|
||||
|
||||
## Как проверено
|
||||
1. `venv/bin/python -c 'import app.main'` — без ошибок.
|
||||
2. Стенд пересобран и здоров (см. SUMMARY 026 — общая пересборка для 026+027).
|
||||
3. Сценарий из плана: 15 неудачных попыток по IP (докер-шлюз) вставлены напрямую в `login_attempts` (порог `MAX_PER_IP = 20`, то есть до блокировки осталось 5); затем 16 параллельных
|
||||
(через `ThreadPoolExecutor`, 16 потоков) неверных входов на 16 разных **несуществующих** логинов с этого же IP:
|
||||
- ответы: 4×401, 12×429;
|
||||
- новых строк в `login_attempts` для этого IP (кроме затравки) — **5** (запрос, доведший счётчик до 20, тоже проверяет пароль и добавляет строку, но получает 429, а не 401 —
|
||||
поэтому 401 на один меньше числа добавленных строк, само число строк не превышает лимит). Соответствует условию плана «добавилось ≤ 5 записей». **PASS.**
|
||||
4. `login_attempts` очищена после проверки.
|
||||
|
||||
## Не проверено
|
||||
- Поведение при коллизии `hashtext` между конкретным логином и конкретным IP в старой (одноаргументной) схеме — не воспроизводилось намеренно, замена на двухаргументную форму
|
||||
устраняет саму возможность, отдельно не тестировалось.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Экран «Префиксы»: без молчаливого усечения (изменение 028)
|
||||
|
||||
Находка № 3 из `docs/reviews/2026-09-26-codebase-review-2.md`, серьёзность — низкая.
|
||||
|
||||
## Context
|
||||
`screens.prefixes` и `screens.address` (`web/app.js`) запрашивают `/prefixes?organization_id=…&limit=1000` одной страницей. Заголовок («N префиксов»), вкладка «Все» и подвал
|
||||
показывают `all.length` (число загруженных), а не `total`. Родительский список в «Новом префиксе», хлебные крошки экрана адресов и размер по умолчанию в «Добавить вложенный (авто)»
|
||||
строятся по усечённому набору. Организация, в которой `/22` разбит на `/30`, быстро превышает 1 000 префиксов.
|
||||
|
||||
## Решение (`web/app.js`, только UI)
|
||||
1. Функция `loadPrefixes(orgId)`: загружает все страницы `/prefixes` (`limit=1000`, `offset += 1000`) до `total`, но не больше `PREFIX_UI_CAP = 20000` префиксов;
|
||||
возвращает `{ items, total }`. Страницы запрашиваются последовательно (порядок `ORDER BY vrf_id, prefix` стабилен).
|
||||
2. `screens.prefixes` и `screens.address` используют `loadPrefixes` вместо одиночного запроса.
|
||||
3. Заголовок, вкладка «Все» и подвал используют `total`. Если `items.length < total` (сработал `PREFIX_UI_CAP`), над таблицей — информационная строка
|
||||
«Загружено X из N префиксов — уточните поиск или выберите VRF» (стиль `.info`).
|
||||
4. Бэкенд не меняется.
|
||||
5. `README.md`, раздел «Поведение таблиц UI»: одна фраза о загрузке всех страниц и пределе 20 000.
|
||||
|
||||
## Файлы
|
||||
`web/app.js`, `README.md`.
|
||||
|
||||
## Проверка
|
||||
`node --check web/app.js`; на стенде временная организация с 1 100 префиксами (`/30` через `POST …/subnets/next`) — в UI заголовок «1 101 префикс», все строки доступны.
|
||||
Данные после проверки удалить.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Итог: экран «Префиксы» без молчаливого усечения (изменение 028)
|
||||
|
||||
## Что сделано
|
||||
- `web/app.js`: добавлена функция `loadPrefixes(orgId)` — последовательно догружает все страницы `/prefixes` (`limit=1000`, `offset` увеличивается на длину полученной страницы) до `total`,
|
||||
но не больше `PREFIX_UI_CAP = 20000` префиксов; возвращает `{ items, total }`.
|
||||
- `screens.prefixes` и `screens.address` используют `loadPrefixes` вместо одиночного запроса с `limit=1000`.
|
||||
- Заголовок («N префиксов»), вкладка «Все» и подвал таблицы теперь используют `pl.total` (реальное общее число), а не `all.length` (число загруженных).
|
||||
- Если сработал предел `PREFIX_UI_CAP` (`items.length < total`), над таблицей выводится информационная строка `.info`: «Загружено X из N префиксов — уточните поиск или выберите VRF».
|
||||
- Бэкенд не менялся.
|
||||
- `README.md`, раздел «Поведение таблиц UI»: добавлено предложение о постраничной догрузке и пределе 20 000.
|
||||
|
||||
## Отклонения от плана
|
||||
Нет.
|
||||
|
||||
## Как проверено
|
||||
1. `node --check web/app.js` — без ошибок.
|
||||
2. Сценарий из плана воспроизведён на API-уровне (браузер недоступен в этой среде, поэтому визуальный рендер заголовка не смотрели глазами, но проверили именно тот запрос/расчёт,
|
||||
который его формирует): временная организация `rv3-page-test` (scratchpad-скрипт), родитель `10.0.0.0/12`, 1100 вложенных `/30` через `POST …/subnets/next` (по одному, как в плане) —
|
||||
всего **1101 префикс**. Затем эмуляция ровно алгоритма `loadPrefixes` (`limit=1000`, `offset += len(page.items)`, до `total`) через прямые вызовы API:
|
||||
- `total=1101`, загружено `1101` элементов за **2 страницы** (1000 + 101) — соответствует «в UI заголовок «1 101 префикс», все строки доступны» из плана. **PASS.**
|
||||
3. Удаление 1100 вложенных префиксов заняло ~32 с, само создание — ~100 с (растёт с числом уже выделенных блоков в одном родителе — ожидаемо, это не предмет изменения 028).
|
||||
Временная организация и все её префиксы удалены по завершении (`org delete: 204`).
|
||||
|
||||
## Не проверено
|
||||
- Собственно визуальный рендер (браузер недоступен в этой среде): заголовок, строка-предупреждение `.info` при срабатывании `PREFIX_UI_CAP`, поведение вкладок и подвала на живой странице.
|
||||
Логика, формирующая эти значения (`pl.total`, `truncated`), проверена чтением кода и совпадает с тем, что подтверждено на уровне API.
|
||||
- Собственно предел `PREFIX_UI_CAP = 20000` (сценарий с >20 000 префиксов) — не воспроизводился (потребовал бы кратно больше времени на создание тестовых данных).
|
||||
@@ -0,0 +1,25 @@
|
||||
# Целостность дерева префиксов при параллельных изменениях (изменение 029)
|
||||
|
||||
Находка № 5 из `docs/reviews/2026-09-26-codebase-review-2.md`, серьёзность — низкая.
|
||||
|
||||
## Context
|
||||
`attach_to_tree` определяет родителя и забирает вложенные префиксы по снимку данных транзакции. Два параллельных запроса могут создать в одном VRF пересекающиеся префиксы разной длины
|
||||
(ручное создание `.0/29` и автовыделение `.4/30`). Уникальность `(vrf_id, prefix)` это не ловит, `parent_id` останется неверным. `FOR UPDATE` в `allocate_subnet` блокирует только строку родителя.
|
||||
|
||||
## Решение (`app/api/v1/prefixes.py`)
|
||||
1. Хелпер `_lock_vrf(db, *vrf_ids)`: `pg_advisory_xact_lock(<VRF_NS>, vrf_id)` для каждого VRF **в порядке возрастания id** (исключает взаимную блокировку); пространство ключей — константа (например, 7033).
|
||||
2. Вызов в начале каждой операции, меняющей дерево или раскладку адресов, до любых чтений дерева:
|
||||
- `create_prefix` — VRF из тела запроса;
|
||||
- `allocate_subnet` — VRF родителя. Сначала прочитать `vrf_id` родителя обычным `SELECT`, затем `_lock_vrf`, затем уже существующий `SELECT … FOR UPDATE`;
|
||||
- `update_prefix` при смене VRF (`_move_to_vrf`) — исходный и целевой VRF;
|
||||
- `delete_prefix` — VRF префикса (переподвешивание детей);
|
||||
- `create_address` и `allocate_next` — VRF префикса (выбор самого узкого префикса и занятых диапазонов должен видеть согласованное дерево).
|
||||
3. Операции над разными VRF не блокируют друг друга. Чтения (`GET`) не блокируются.
|
||||
4. `README.md`: одна фраза в разделе «Модель данных» — изменения дерева одного VRF выполняются по одному.
|
||||
|
||||
## Файлы
|
||||
`app/api/v1/prefixes.py`, `README.md`.
|
||||
|
||||
## Проверка
|
||||
Параллельно (потоки) в одном VRF: создание `.0/29` и автовыделение `/30` из `/24` → у выделенного `/30`, если он внутри `/29`, родитель — `/29`.
|
||||
Цикл из 20 повторов без ошибок и без неверных родителей. Данные после проверки удалить.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Итог: целостность дерева префиксов при параллельных изменениях (изменение 029)
|
||||
|
||||
## Что сделано
|
||||
- `app/api/v1/prefixes.py`: константа `VRF_LOCK_NS = 7033` и хелпер `_lock_vrf(db, *vrf_ids)` — `pg_advisory_xact_lock(VRF_LOCK_NS, vrf_id)` для каждого VRF, отсортированных
|
||||
по возрастанию id (исключает взаимную блокировку при операциях над несколькими VRF).
|
||||
- Вызов добавлен в начале каждой операции, меняющей дерево или раскладку адресов, до любых чтений дерева:
|
||||
- `create_prefix` — по `body.vrf_id`, первой строкой обработчика;
|
||||
- `allocate_subnet` — сначала обычный `SELECT vrf_id` префикса-родителя (без блокировки строки), затем `_lock_vrf`, затем уже существующий `SELECT … FOR UPDATE`;
|
||||
- `update_prefix` при смене VRF — по исходному и целевому VRF, до вызова `_move_to_vrf`;
|
||||
- `delete_prefix` — по VRF префикса, до переподвешивания детей;
|
||||
- `create_address` — по VRF префикса, до проверки «самый узкий префикс»;
|
||||
- `allocate_next` — обычный `SELECT vrf_id`, затем `_lock_vrf`, затем существующий `SELECT … FOR UPDATE` по префиксу.
|
||||
- Чтения (`GET`) не блокируются; операции над разными VRF друг друга не блокируют (независимые ключи advisory-lock).
|
||||
- `README.md`, раздел «Модель данных»: одно предложение о сериализации изменений дерева одного VRF.
|
||||
|
||||
## Отклонения от плана
|
||||
Нет.
|
||||
|
||||
## Как проверено
|
||||
1. `venv/bin/python -c 'import app.main'` — без ошибок.
|
||||
2. Стенд пересобран, `healthy`.
|
||||
3. Сценарий из плана (20 повторов, временная организация `rv3-tree-test`, скрипт в scratchpad): в каждой итерации — свой `/24`-родитель, и **параллельно** (два потока) —
|
||||
явное создание `.0/29` и автовыделение `/30` из родителя (`POST …/subnets/next {length:30}`). Оба запроса каждый раз завершились без ошибок (0 ошибок из 40 запросов).
|
||||
Итоговое состояние дерева (отдельный `GET` **после** завершения обоих запросов пары — не тело ответа гонки, оно отражает лишь момент собственного commit) проверено на всех
|
||||
20 итерациях: во всех случаях, когда выделенный `/30` оказался внутри `.0/29`, его `parent_id` равен id `.0/29`, а не `.0/24`. **0 из 20 неверных `parent_id`. PASS.**
|
||||
- Первый прогон теста дал 5 ложных срабатываний из-за ошибки самого теста (сверка велась по телу HTTP-ответа каждого из двух конкурентных запросов, которое отражает
|
||||
состояние дерева на момент commit именно этого запроса, а не финальное состояние после обоих); после исправления теста (повторный `GET` уже обоих префиксов после
|
||||
завершения гонки) все 20 итераций прошли успешно, а прямая проверка в БД (`SELECT … FROM prefixes`) подтвердила, что для всех 5 «проблемных» по первому прогону
|
||||
итераций конечное состояние в базе уже было верным (гонка не оставляла ошибочных данных, ошибался только клиентский тест).
|
||||
4. Временная организация и все её префиксы удалены по завершении.
|
||||
|
||||
## Не проверено
|
||||
- Гонка при `update_prefix` (смена VRF, две блокировки) и при одновременном `create_address`/`allocate_next` в одном VRF — проверялась только комбинация
|
||||
`create_prefix` + `allocate_subnet`, явно описанная в плане; остальные точки вызова `_lock_vrf` проверены только чтением кода.
|
||||
- Поведение под большей степенью параллелизма (более двух одновременных запросов) и при конкурентных операциях над разными VRF (что они не блокируют друг друга) — не измерялось.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Исправление замечаний 1–2 ревью изменений 025–029 (изменение 030)
|
||||
|
||||
Источник: `docs/reviews/2026-09-26-changes-025-029-review.md`, замечания № 1 и № 2. Только код. Тесты исполнитель не пишет и не запускает (тестирование — отдельный шаг).
|
||||
|
||||
## 1. Чтение префикса до блокировки VRF (низкая, 029)
|
||||
**Где:** `app/api/v1/prefixes.py` — `delete_prefix`, `update_prefix` (при смене VRF), `create_address`.
|
||||
**Суть:** префикс читается через `get_or_404` **до** `_lock_vrf`. Возникают две проблемы:
|
||||
- в `delete_prefix` устаревший `p.parent_id` используется для переподвешивания детей;
|
||||
- если префикс параллельно переносят в другой VRF, блокируется не тот VRF (`p.vrf_id` прочитан до блокировки).
|
||||
|
||||
**Решение:**
|
||||
1. Хелпер `_lock_prefix(db, id: int, *extra_vrf_ids: int) -> Prefix` рядом с `_lock_vrf`:
|
||||
- прочитать `vrf_id` префикса отдельным `SELECT Prefix.vrf_id`; нет строки — 404 «Префикс не найден»;
|
||||
- `_lock_vrf(db, vrf_id, *extra_vrf_ids)` — все VRF одним вызовом, порядок по возрастанию id уже обеспечен `_lock_vrf`;
|
||||
- перечитать префикс под блокировкой: `select(Prefix).where(Prefix.id == id).with_for_update().execution_options(populate_existing=True)`,
|
||||
чтобы не взять устаревший объект из identity map сессии;
|
||||
- если перечитанный `vrf_id` отличается от заблокированного (префикс успели перенести), повторить цикл, не больше 3 попыток, затем 409 «Префикс одновременно изменяется, повторите запрос».
|
||||
Блокировки транзакции при повторе не снимаются, это допустимо: порядок захвата внутри одной попытки по-прежнему возрастающий.
|
||||
Если «лишняя» блокировка ухудшает порядок между попытками, допустимо вместо повтора сразу возвращать 409.
|
||||
Выбрать вариант и указать его в SUMMARY.
|
||||
2. Применение:
|
||||
- `delete_prefix`: `p = _lock_prefix(db, id)` вместо `get_or_404` + `_lock_vrf`;
|
||||
- `create_address`: то же;
|
||||
- `update_prefix`: целевой VRF (`body.vrf_id`) известен из тела запроса до чтения префикса, поэтому `p = _lock_prefix(db, id, new_vrf)` при заданном `vrf_id`, иначе обычный `get_or_404` (без смены VRF дерево не меняется).
|
||||
Если `new_vrf` совпал с текущим `p.vrf_id`, лишняя блокировка того же VRF безвредна.
|
||||
- `allocate_subnet` и `allocate_next` уже читают `vrf_id` до блокировки; перевести их на `_lock_prefix` для единообразия, сохранив поведение (`FOR UPDATE` строки префикса есть в хелпере).
|
||||
3. Комментарии — кратко, со ссылкой на изменение 030.
|
||||
|
||||
## 2. Зависимость ротации от API-слоя (низкая, 026)
|
||||
**Где:** `app/rotation.py` импортирует `KNOWN_IP_DAYS` из `app.api.v1.auth`.
|
||||
**Решение:**
|
||||
1. Перенести константы политики входа — `LOGIN_WINDOW` (сейчас `WINDOW`), `MAX_PER_LOGIN_IP`, `MAX_PER_IP`, `MAX_PER_LOGIN`, `KNOWN_IP_DAYS` — в `app/services.py` отдельным блоком с комментарием.
|
||||
2. `app/api/v1/auth.py` импортирует их из `app.services`; внутренние имена в `auth.py` не должны расходиться с перенесёнными. `WINDOW` заменить на `LOGIN_WINDOW` по месту, модульная docstring с `.format(...)` продолжает работать.
|
||||
3. `app/rotation.py` импортирует `KNOWN_IP_DAYS` из `app.services`; импорт `app.api.v1.auth` удалить.
|
||||
4. Пространства ключей advisory-lock (`LOGIN_LOCK_NS`, `IP_LOCK_NS`) остаются в `auth.py`.
|
||||
|
||||
## Артефакты
|
||||
- `docs/changes/030-review-fixes-025-029/SUMMARY.md`: что сделано, выбранный вариант повтора из п. 1, отклонения.
|
||||
- `README.md` — только если меняется описанное поведение (ожидается, что не меняется).
|
||||
|
||||
## Проверка (выполняет ревьюер, не исполнитель)
|
||||
- Импорт приложения; `rotation.py` не импортирует API-модули.
|
||||
- Параллельные сценарии по п. 1: удаление префикса ∥ вставка промежуточного префикса; перенос префикса в другой VRF ∥ создание адреса / удаление.
|
||||
- Регрессия: `pytest -q`.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Итог: исправление замечаний 1–2 ревью изменений 025–029 (изменение 030)
|
||||
|
||||
Источник: `docs/reviews/2026-09-26-changes-025-029-review.md`, замечания № 1 и № 2. Только код, без тестов.
|
||||
|
||||
## 1. Чтение префикса до блокировки VRF (`app/api/v1/prefixes.py`)
|
||||
|
||||
- Добавлен хелпер `_lock_prefix(db, id, *extra_vrf_ids) -> Prefix` рядом с `_lock_vrf`:
|
||||
1. `vrf_id` читается отдельным `SELECT Prefix.vrf_id` (без блокировки строки); нет строки — 404 «Префикс не найден».
|
||||
2. `_lock_vrf(db, vrf_id, *extra_vrf_ids)` — все нужные VRF одним вызовом, в возрастающем порядке (как раньше).
|
||||
3. Префикс перечитывается под блокировкой: `select(Prefix).where(Prefix.id == id).with_for_update().execution_options(populate_existing=True)` —
|
||||
`populate_existing`, чтобы не получить устаревший объект из identity map сессии.
|
||||
4. Если перечитанный `vrf_id` не совпал с заблокированным (префикс успели перенести в другой VRF между шагами 1 и 3) — `409 «Префикс одновременно изменяется, повторите запрос»`.
|
||||
|
||||
- Применение:
|
||||
- `delete_prefix`: `p = _lock_prefix(db, id)` вместо `get_or_404` + `_lock_vrf(db, p.vrf_id)`.
|
||||
- `create_address`: то же самое.
|
||||
- `update_prefix`: `new_vrf` вычисляется из тела запроса **до** чтения префикса (`data.pop("vrf_id", None)`); дальше
|
||||
`p = _lock_prefix(db, id, new_vrf) if new_vrf is not None else get_or_404(db, Prefix, id, "Префикс")`. Если целевой VRF совпадает
|
||||
с текущим, лишняя (повторная) блокировка того же VRF безвредна — как и предполагал план.
|
||||
- `allocate_subnet`, `allocate_next`: переведены на `_lock_prefix` для единообразия. Прежний код читал `vrf_id` отдельным `SELECT`,
|
||||
затем `_lock_vrf`, затем `SELECT … FOR UPDATE` без сверки `vrf_id` после блокировки — сейчас эта сверка выполняется хелпером,
|
||||
т.е. поведение не просто перенесено, а дополнительно защищено той же гарантией, что и остальные точки.
|
||||
|
||||
### Выбранный вариант обработки гонки — без повтора, сразу 409
|
||||
|
||||
В плане предлагалось два варианта: (а) до 3 попыток внутри `_lock_prefix`, не снимая блокировки между попытками, либо
|
||||
(б) сразу отдавать 409 без повтора. Выбран вариант **(б)**.
|
||||
|
||||
Причина: между двумя попытками цикла блокировка устаревшего (первого прочитанного) `vrf_id` из предыдущей попытки не снимается
|
||||
(advisory-lock держится до конца транзакции), а на следующей попытке блокируется уже новый `vrf_id`. `_lock_vrf` сортирует по
|
||||
возрастанию только ids **внутри одного своего вызова** — порядок между накопленными за разные попытки блокировками этой
|
||||
гарантии не имеет. Если два конкурентных запроса одновременно переносят префиксы «навстречу» друг другу (транзакция A: сначала
|
||||
видит VRF 5, после гонки — VRF 3; транзакция B — в обратном порядке), возможна ситуация, когда A держит блокировку VRF 5 и
|
||||
запрашивает VRF 3, а B держит VRF 3 и запрашивает VRF 5 — классический deadlock из-за несогласованного порядка захвата между
|
||||
попытками. Однократная попытка с немедленным 409 гарантированно не накапливает блокировки разных VRF за пределами одного
|
||||
согласованного вызова `_lock_vrf`, поэтому этот риск не возникает. Транзакция откатывается при закрытии сессии (as-is для всех
|
||||
остальных `HTTPException` в этом модуле — см., например, комментарий у `_move_to_vrf`), и клиент просто повторяет весь HTTP-запрос
|
||||
с чистого листа, без унаследованных блокировок.
|
||||
|
||||
Это разумная цена: окно гонки узкое (перенос префикса в другой VRF — редкая административная операция), а 3 внутренние попытки
|
||||
не устраняют риск deadlock — они его создают.
|
||||
|
||||
## 2. Зависимость ротации от API-слоя
|
||||
|
||||
- `app/services.py`: добавлен блок констант политики входа (после `MAX_CAPACITY`/`MAX_OFFSET`, с комментарием):
|
||||
`LOGIN_WINDOW` (бывший `WINDOW`), `MAX_PER_LOGIN_IP`, `MAX_PER_IP`, `MAX_PER_LOGIN`, `KNOWN_IP_DAYS`. Добавлен импорт `from datetime import timedelta`.
|
||||
- `app/api/v1/auth.py`: собственные определения констант удалены, всё импортируется из `app.services`. `WINDOW` заменён на
|
||||
`LOGIN_WINDOW` по всем местам использования (`_retry_after`, `_distinct_logins`, форматирование модульной docstring). Внутренние
|
||||
имена не разошлись с перенесёнными. `LOGIN_LOCK_NS`/`IP_LOCK_NS` (пространства advisory-lock) остались в `auth.py`, как и планировалось.
|
||||
- `app/rotation.py`: `from app.api.v1.auth import KNOWN_IP_DAYS` заменён на `from app.services import KNOWN_IP_DAYS, SYSTEM, audit`.
|
||||
Модуль больше не импортирует ничего из `app.api.*`.
|
||||
|
||||
## Отклонения от плана
|
||||
|
||||
- П. 1: выбран вариант «сразу 409» вместо цикла до 3 попыток — обоснование выше (план явно допускал оба варианта и просил
|
||||
зафиксировать выбор в SUMMARY).
|
||||
- В остальном реализация соответствует плану без отклонений.
|
||||
|
||||
## Изменённые файлы
|
||||
|
||||
- `app/api/v1/prefixes.py` — хелпер `_lock_prefix`, применение в `delete_prefix`, `create_address`, `update_prefix`, `allocate_subnet`, `allocate_next`.
|
||||
- `app/services.py` — константы политики входа.
|
||||
- `app/api/v1/auth.py` — импорт констант из `app.services` вместо локальных определений.
|
||||
- `app/rotation.py` — импорт `KNOWN_IP_DAYS` из `app.services` вместо `app.api.v1.auth`.
|
||||
|
||||
`README.md` не менялся: наблюдаемое поведение API не меняется (кроме нового 409 при воспроизведении узкой гонки переноса VRF,
|
||||
который относится к тому же классу конкурентных ошибок, что уже описан в README для изменения 029).
|
||||
|
||||
## Не проверено
|
||||
|
||||
Всё поведение — по правилам задачи, автор изменения тесты не пишет и не запускает. Не проверялось (проверяет ревьюер):
|
||||
- Импорт приложения проверен только `import app.main`; `pytest -q` не запускался.
|
||||
- Параллельные сценарии из плана: удаление префикса ∥ вставка промежуточного префикса; перенос префикса в другой VRF ∥
|
||||
создание адреса / удаление — не воспроизводились ни через API, ни вручную.
|
||||
- Что `_lock_prefix` действительно возвращает 409 при воспроизведённой гонке (реальный `vrf_id` mismatch под нагрузкой).
|
||||
- Поведение `populate_existing=True` в связке с уже загруженными объектами `Prefix` в сессии (в текущих точках вызова такой
|
||||
объект до `_lock_prefix` не загружается, поэтому эффект защитный, но не критичен для текущих путей вызова).
|
||||
- Корректность обновлённой модульной docstring `auth.py` после форматирования (`__doc__.format(...)`) — визуально не отличается
|
||||
от прежней, но не выводилась и не сравнивалась построчно.
|
||||
- `rotation.py`: что при импорте больше не вычисляется фиктивный argon2-хэш из `auth.py` (косвенный эффект удаления импорта,
|
||||
отдельно не измерялся).
|
||||
Reference in new issue
Block a user