Приём отвечает 200 до свёртки, свёртку ведёт фоновый воркер
- Очередью служит сама таблица: доставка ждёт свёртки в статусе `pending`, канал несёт только бит «есть работа». Переполнять нечего, падение процесса очередь не теряет, а подбор `pending` при старте — обычный проход воркера, а не отдельный код. Классификация исхода общая с пересборкой журнала. - Исход разбора начал отражать доставку, а не обстоятельства: отмена и занятость базы статус не меняют (иначе конкуренция за базу выводила бы доставку из очереди навсегда), паника свёртки больше не валит процесс, а учёт доставки идёт через транзакцию с повторами. - Длинный бюджет ответа выдан маршруту приёма, а не всему серверу: `write_timeout` в Go покрывает и чтение тела, и общий подъём снял бы защиту с остальных маршрутов.
This commit is contained in:
+80
-1
@@ -223,9 +223,24 @@ HRV); у накопительных — только `date`. Поэтому то
|
||||
```
|
||||
запрос → токен → лимит тела, gzip → проверка формы JSON
|
||||
→ запись тела в архив → строка в delivery → 200
|
||||
→ разбор → запись в витрину
|
||||
↓
|
||||
фоновый воркер: разбор → запись в витрину
|
||||
```
|
||||
|
||||
**Ответ отдаётся до свёртки, и это контракт, а не деталь реализации.** `200`
|
||||
означает «тело сохранено и учтено»; разобрано ли оно, говорит
|
||||
`delivery.parse_status`, и говорит позже. Причина измерена: свёртка 16 тысяч
|
||||
точек занимает 11 секунд, а `WriteTimeout` в Go ставится в `readRequest` — то
|
||||
есть до вызова обработчика — и потому является общим бюджетом на чтение тела,
|
||||
запись архива, учёт и свёртку. Исчерпав его, сервер считает, что отдал `200`,
|
||||
клиент получает обрыв, а `accessLog` пишет `status_code=200`: единственный канал
|
||||
наблюдаемости врёт. Бьёт это по широким проходам — ровно по тем, ради которых
|
||||
заведён инвариант «дыры закрываются сами».
|
||||
|
||||
Отсюда же второй бюджет: длинный дедлайн ответа выставляет **сам обработчик
|
||||
приёма**, а не конфиг сервера. `write_timeout` глобален, и поднять его значило бы
|
||||
снять защиту от застрявшей записи со всех маршрутов ради одного.
|
||||
|
||||
Код ответа определяется **доставкой**, не разбором:
|
||||
|
||||
- **400** — тело не разбирается как JSON ожидаемой верхнеуровневой формы.
|
||||
@@ -236,6 +251,70 @@ HRV); у накопительных — только `date`. Поэтому то
|
||||
безопасности, исход разбора виден в логе, в `delivery.parse_status` и в
|
||||
`/stats`, а доразобрать их можно командой `reindex`.
|
||||
|
||||
#### Очередь свёртки — таблица, а не структура в памяти
|
||||
|
||||
Доставка ждёт свёртки в собственном статусе `pending`; канал между приёмом и
|
||||
воркером несёт один бит «есть работа». Это **transactional outbox**, он же «база
|
||||
как очередь заданий»: состояние задания пишется той же базой, что и факт
|
||||
события, а фоновый процесс выбирает необработанные строки.
|
||||
|
||||
Три следствия, ради которых так и сделано:
|
||||
|
||||
- **переполнять нечего** — доставка `pending` всегда, пока не свёрнута, поэтому
|
||||
«очередь переполнена» невыразимо;
|
||||
- **падение процесса очереди не теряет** — транзакция свёртки откатывается,
|
||||
статус остаётся `pending`;
|
||||
- **подбор `pending` при старте не является отдельным кодом** — это обычный
|
||||
проход воркера, а не особый режим.
|
||||
|
||||
Отвергнут **канал идентификаторов в памяти**: он вводит второе, недолговечное
|
||||
представление того же факта, и эти два расходятся при каждом падении; политика
|
||||
переполнения всё равно требует подбора из базы, то есть того же кода — только в
|
||||
двух экземплярах. Отвергнут и **опрос по таймеру вместо сигнала**: полпериода
|
||||
задержки на каждую доставку без пользы. Тик при этом взят **в дополнение** к
|
||||
сигналу: доставка, оставшаяся в очереди по обстоятельствам, иначе ждала бы
|
||||
следующей доставки, а ночью телефон молчит часами.
|
||||
|
||||
Воркер один, и порядок у него тот же, что у пересборки — `(received_at, id)`:
|
||||
слой доставки без плотных метрик наследуется от предшествующей доставки той же
|
||||
автоматизации, то есть является функцией префикса журнала. Обещается достижимое:
|
||||
в этом порядке сворачивается всё, что **видно воркеру** на момент выборки;
|
||||
абсолютного порядка при конкурентных приёмах нет и быть не может без сериализации
|
||||
самого приёма.
|
||||
|
||||
Классификацию исхода свёртки воркер и пересборка делят (`internal/replay`):
|
||||
второй классификатор разошёлся бы с первым молча, а по одному из его счётчиков
|
||||
(`partial`) принимается решение о судьбе тела в архиве.
|
||||
|
||||
**Исход разбора отражает доставку, а не обстоятельства.** Отмена и занятость
|
||||
базы статус не меняют — доставка остаётся `pending` и будет свёрнута снова;
|
||||
непонятое содержимое, невыводимый слой, нечитаемое тело, исчерпанный дедлайн и
|
||||
паника свёртки дают `failed`. Различение появилось не из аккуратности: `failed`
|
||||
из очереди выбывает навсегда и возвращается только пересборкой, а конкуренция за
|
||||
базу между приёмом и свёрткой стала штатной — без него занятость стирала бы
|
||||
доставку с полки молча. По той же причине учёт доставки идёт через транзакцию с
|
||||
повторами: одиночная вставка пересиживала бы только `busy_timeout`, после чего
|
||||
приём ответил бы `500` по доставке, тело которой уже на диске.
|
||||
|
||||
**Паника свёртки перехватывается там же, где пишется исход разбора.** Пока
|
||||
свёртка шла внутри обработчика, панику ловил транспорт и стоила она одного
|
||||
ответа; из фоновой горутины она валит процесс, а перезапуск берёт ту же доставку
|
||||
первой — дефект одной доставки становится циклом перезапуска, при котором приём
|
||||
не работает вовсе.
|
||||
|
||||
**Предел порядка назван вслух.** Метка приёма фиксируется раньше, чем строка
|
||||
учёта становится видимой, поэтому две одновременные доставки могут закоммитить
|
||||
строки в обратном порядке. Доставка без плотных метрик, свёрнутая раньше своей
|
||||
предшественницы, слоя не выведет и уйдёт в `failed`: её точки доедут только
|
||||
пересборкой. Окно узкое, и изменение его сужает, а не открывает, — но закрытие
|
||||
предела требует удерживать порядок на самом приёме, и это отдельный вопрос
|
||||
(беклог, блокеры).
|
||||
|
||||
Остановка формулируется **инвариантом**: приём прекращается раньше воркера, и
|
||||
после остановки не существует доставки, которая числится разобранной, а записана
|
||||
наполовину. Обещать «текущая доставка досворачивается» нельзя — бюджет остановки
|
||||
(30 с) меньше бюджета свёртки (2 мин).
|
||||
|
||||
#### Частичный разбор
|
||||
|
||||
Разбор покрывает секцию `metrics`; `workouts`, `stateOfMind`, `symptoms`, `ecg`
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
либо берётся, либо отвергается с названной причиной.
|
||||
|
||||
## блокеры
|
||||
- [Порядок журнала при конкурентных приёмах](poryadok-zhurnala-na-priyome.md) — доставка, свёрнутая раньше своей предшественницы, уходит в failed навсегда — живое состояние расходится с reindex
|
||||
|
||||
## высокий
|
||||
- [Тренировки и секции с собственными id](trenirovki-i-zapisi.md) — Тренировки с геотреком и состояние разума приходят, но не разбираются — без них не закрыть ни трекер, ни агента-медика
|
||||
@@ -25,7 +26,6 @@
|
||||
- [Read API: точки, выбор слоя, свёртка по сетке](read-api-tochki.md) — Данные видны только через sqlite на хосте — ни один из трёх потребителей ничего прочитать не может
|
||||
- [OpenAPI-спека и Swagger UI](openapi-swagger.md) — Потребителей три и один из них агент — контракт должен читаться машиной, а не пересказываться в чате
|
||||
- [MCP-сервер поверх Read API](mcp-server.md) — Агент-медик — первый заказчик проекта, а подключить его сейчас нечем
|
||||
- [Разнести ответ приёма и свёртку доставки](otvet-i-svyortka.md) — синхронная свёртка не помещается в write_timeout: широкие проходы получают обрыв вместо 200
|
||||
|
||||
## средний
|
||||
- [Словарь категориальных значений → коды HealthKit](slovar-kategorialnyh-znachenij.md) — Фазы сна и типы тренировок приходят строками русской локали — с экспортом Apple их не сверить
|
||||
|
||||
@@ -52,7 +52,11 @@ Form одной точки 2.2 мкс
|
||||
|
||||
## Связано
|
||||
|
||||
- [otvet-i-svyortka](otvet-i-svyortka.md) — воркер убирает влияние на ответ
|
||||
приёму, но не на блокировку записи; задачи независимы.
|
||||
- [reindex-iz-arhiva](reindex-iz-arhiva.md) — подбирает доставки, ушедшие в
|
||||
`failed` по этой причине.
|
||||
- Разнесение ответа приёма и свёртки **сделано** (архив change
|
||||
`2026-08-02-otvet-i-svyortka`): воркер убрал влияние на время ответа, но не на
|
||||
блокировку записи — длинная транзакция слияния держит её по-прежнему. Заодно
|
||||
оттуда взято главное смягчение: занятость базы больше не выводит доставку из
|
||||
очереди, она остаётся `pending` и пересворачивается. Оракул окна —
|
||||
`task verify:busy`.
|
||||
- Пересборка (`healthlog reindex`) подбирает доставки, ушедшие в `failed` по
|
||||
другим причинам.
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
# Разнести ответ приёма и свёртку доставки
|
||||
|
||||
**Приоритет:** высокий
|
||||
|
||||
Была блокером, вынутым ревью кода задачи `razbor-metrik-v-obekty` (профиль
|
||||
`deep`, находка №4 триажа, severity major). **Решение принято** — ниже задача.
|
||||
|
||||
## Что не так сегодня
|
||||
|
||||
Свёртка выполняется **синхронно внутри обработчика запроса**, поэтому время
|
||||
ответа равно времени свёртки.
|
||||
|
||||
`WriteTimeout` в Go ставится в `readRequest` — **до** чтения тела и до вызова
|
||||
обработчика (`net/http/server.go:993-997`, прочитано в исходниках). Значит
|
||||
30 секунд по умолчанию это бюджет на всё сразу: дочитать до 64 МиБ по
|
||||
мобильной сети, сделать `fsync` архива, вставить доставку и свернуть.
|
||||
|
||||
Воспроизведено минимальной программой: сервер с `WriteTimeout=200ms`,
|
||||
обработчик спит 500 мс.
|
||||
|
||||
```
|
||||
handler: WriteHeader(200), body Write err=<nil>
|
||||
client: elapsed=501ms err=EOF
|
||||
```
|
||||
|
||||
Сервер считает, что отдал `200` — ошибки записи не видно, ответ ушёл в буфер и
|
||||
сбрасывается позже. Клиент получил обрыв. Код обработчика этого не видит, а
|
||||
`accessLog` честно запишет `status_code=200`: единственный сегодняшний канал
|
||||
наблюдаемости в этом сценарии врёт.
|
||||
|
||||
Стоимость свёртки измерена **до** перехода на одну транзакцию на доставку:
|
||||
|
||||
| тело | объектов | свёртка |
|
||||
|---|---|---|
|
||||
| 80 КиБ | 1001 | 815 мс |
|
||||
| 323 КиБ | 4001 | 3.07 с |
|
||||
| 1302 КиБ | 16001 | 11.07 с |
|
||||
|
||||
Одна транзакция на доставку убрала около 0.7 мс на объект (прогон живого
|
||||
архива ускорился с 64 до 52 секунд), но порядок величины остался: широкая
|
||||
доставка по-прежнему измеряется секундами.
|
||||
|
||||
Бьёт это по **широким проходам** — `Today`, `Previous 7 Days`, ручной
|
||||
экспорт, — то есть ровно по тем, ради которых заведён инвариант «дыры
|
||||
закрываются сами».
|
||||
|
||||
## Что решено
|
||||
|
||||
Вариант (а): **отвечать `200` сразу после архивации и учёта; свёртка —
|
||||
воркером в порядке журнала, с подбором `pending` при старте.**
|
||||
|
||||
Почему он, а не альтернативы:
|
||||
|
||||
- Поднять `write_timeout` до согласованного с `foldTimeout` — дёшево, но
|
||||
худший случай (64 МиБ) всё равно минуты, и молчание `accessLog` остаётся.
|
||||
Это лечит симптом.
|
||||
- Оставить как есть — широкие проходы продолжают рваться.
|
||||
|
||||
Вариант (а) решает причину и попутно снимает две смежные дыры: параллельные
|
||||
доставки одной автоматизации перестают гонять наследование слоя (сейчас вторая
|
||||
может не найти слоя первой и уйти в `failed`), и доставка, застрявшая в
|
||||
`pending` из-за сбоя записи, наконец кем-то подбирается.
|
||||
|
||||
## Что делать
|
||||
|
||||
1. Воркер свёртки: одна горутина, очередь идентификаторов доставок, обработка
|
||||
**строго в порядке журнала** (`received_at`, `id`) — от этого зависит
|
||||
наследование слоя и воспроизводимость.
|
||||
2. Приём отвечает `200` после архивации и вставки доставки; свёртку ставит в
|
||||
очередь. Очередь переполнена — доставка остаётся `pending`, это не отказ.
|
||||
3. Подбор `pending` при старте, тем же путём. Это половина `reindex`, поэтому
|
||||
код должен быть общим с ним, а не соседним.
|
||||
4. Остановка сервиса дожидается текущей доставки: свёртка — одна транзакция,
|
||||
рвать её нечем, но очередь надо дренировать осознанно.
|
||||
5. Метка «доставка ждала свёртки дольше N» — в наблюдаемость, чтобы отставание
|
||||
воркера было видно до того, как оно станет отставанием на сутки.
|
||||
6. Тесты: порядок журнала соблюдается при конкурентных доставках; `pending`
|
||||
подбирается при старте; отмена контекста не оставляет половинчатого
|
||||
состояния; `task verify:archive` даёт то же состояние.
|
||||
|
||||
## Что стоит без решения
|
||||
|
||||
Ничего: свёртка работает, просто рискует не уложиться в таймаут на самых
|
||||
широких доставках. Данные при этом не теряются — тело ложится в архив **до**
|
||||
свёртки.
|
||||
|
||||
## Связано
|
||||
|
||||
- [reindex-iz-arhiva](reindex-iz-arhiva.md) — подбор `pending` это её половина;
|
||||
делать одним кодом.
|
||||
- [stats-nablyudaemost](stats-nablyudaemost.md) — метка «ответ не уложился в
|
||||
таймаут» и отставание воркера должны попасть туда.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Порядок журнала при конкурентных приёмах
|
||||
|
||||
**Приоритет:** блокеры
|
||||
|
||||
Вынут ревью кода задачи «Разнести ответ приёма и свёртку доставки» (профиль
|
||||
`deep`, враждебный проход, находка с построенным путём и прогоном).
|
||||
|
||||
## Что решить
|
||||
|
||||
Метка `received_at` доставки фиксируется в момент выпуска ULID — **до** записи
|
||||
тела в архив и до вставки строки учёта. Порядок, в котором строки становятся
|
||||
видимыми воркеру, порядку меток не подчиняется: между выпуском идентификатора и
|
||||
коммитом строки проходит запись тела (измерено 184 мс на 62 МиБ) плюс ожидание
|
||||
занятой базы (до пяти секунд, а с повторами транзакции дольше).
|
||||
|
||||
Путь построен и прогнан:
|
||||
|
||||
1. Широкая доставка **A** автоматизации X получает `received_at = T1` и уходит
|
||||
писать тело.
|
||||
2. Узкая доставка **B** той же автоматизации (`T2 > T1`, только `sleep_analysis`,
|
||||
плотных метрик нет) успевает закоммитить строку первой и будит воркер.
|
||||
3. Воркер видит только B, сворачивает её, наследовать слой не от кого →
|
||||
`ErrLayerUnknown` → `failed`.
|
||||
4. `failed` фоновая свёртка не подбирает никогда. Точки B в витрину не попадут.
|
||||
|
||||
Измерено на фикстурах: живой приём даёт `B=failed` и ноль часов
|
||||
`sleep_analysis/minute`; журнальный порядок — `B=parsed` и два часа. То есть
|
||||
живое состояние расходится с тем, что даст `healthlog reindex`, и расхождение
|
||||
молчит: уровень лога у этого исхода `WARN`, такой же, как у штатного «у этой
|
||||
автоматизации плотных метрик не бывает».
|
||||
|
||||
**Это не регресс** — прежде свёртка шла в порядке завершения обработчиков, то
|
||||
есть было хуже. Изменение окно сузило и назвало предел в спеке приёма; вопрос в
|
||||
том, закрывать ли его совсем.
|
||||
|
||||
## Варианты и цена
|
||||
|
||||
**а. Резервировать строку учёта в начале `Accept`** (до записи тела), дописывая
|
||||
`raw_path`/`bytes`/`sha256` после. Тогда видимость строки монотонна вместе с
|
||||
`received_at`. Цена: ломается инвариант «тело на диск раньше строки учёта»,
|
||||
заведённый ровно затем, чтобы не было учтённой доставки без данных; появляется
|
||||
новое состояние «строка есть, тела ещё нет», которое обязаны понимать пересборка
|
||||
и ретеншен.
|
||||
|
||||
**б. Откладывать свёртку доставки, пока она не «устоялась»** — не сворачивать
|
||||
моложе N секунд. Цена: задержка N на каждую доставку и произвольное N: окно
|
||||
занятости базы измерено до пяти секунд и зависит от нагрузки, так что N честно
|
||||
не выбрать.
|
||||
|
||||
**в. `ErrLayerUnknown` в живом пути не выводит доставку из очереди** —
|
||||
ограниченное число повторов, потом `failed`. Цена: колонка счётчика попыток
|
||||
(миграция) и политика «сколько попыток достаточно»; зато лечит и прочие случаи
|
||||
«предшественница ещё не доехала». Требует правки спеки хранения («отказ разбора
|
||||
⇒ `failed`»).
|
||||
|
||||
**г. Ничего не делать**, оставив предел названным в спеке. Цена: редкая,
|
||||
молчаливая потеря точек у автоматизаций без плотных метрик; лечится
|
||||
`healthlog reindex` с остановкой сервиса и ручной подменой базы, но узнать о
|
||||
необходимости неоткуда — счётчика `failed` в рантайме нет.
|
||||
|
||||
## Что заблокировано
|
||||
|
||||
Ничего: задача про разнесение ответа и свёртки доведена до конца в объявленных
|
||||
границах, предел записан в спеке приёма. Заблокировано только **закрытие**
|
||||
предела.
|
||||
|
||||
Смежно: пока предел жив, полезно уметь сверять живую витрину с пересборкой —
|
||||
`reindex` уже печатает оба отпечатка, но по расписанию их никто не сравнивает.
|
||||
|
||||
## Рекомендация
|
||||
|
||||
**(в)**, но не раньше `/stats`: сперва должно стать видно, сколько доставок
|
||||
числится `failed` и как давно, — иначе повторы будут лечить болезнь, которую
|
||||
никто не наблюдает. До тех пор — (г) с уже записанным пределом.
|
||||
@@ -14,5 +14,13 @@
|
||||
Готово, когда по одному запросу видно, какая из автоматизаций замолчала и
|
||||
когда.
|
||||
|
||||
Отдельной строкой — **отставание фоновой свёртки**: длина очереди
|
||||
(`parse_status = 'pending'`) и возраст самой старой неразобранной доставки.
|
||||
Сегодня об этом говорят только две метки в логе (`WARN` «доставка ждала свёртки
|
||||
дольше пяти минут» и `INFO` о размере задолженности при старте), а `/healthz`
|
||||
статичен и здорового сервиса от сервиса с сотней несвёрнутых тел не отличает.
|
||||
Пришло из задачи «Разнести ответ приёма и свёртку доставки»: там числа
|
||||
намеренно не заводились, чтобы не предрешать форму счётчиков этой задачи.
|
||||
|
||||
Активное уведомление — отдельная задача, здесь только факт.
|
||||
|
||||
|
||||
+8
-1
@@ -56,7 +56,14 @@ SQLite (`modernc.org/sqlite`, чистый Go), миграции — goose, фа
|
||||
| `derived_layer` | слой, выведенный для этой доставки. Нужен не отчётности, а самому выводу: доставка без плотных метрик наследует последний надёжно выведенный слой той же автоматизации, и без хранения этой памяти первая такая доставка после перезапуска осталась бы без слоя |
|
||||
|
||||
Индексы: `delivery_received_at` (порядок журнала), `delivery_sha256` (учёт
|
||||
повторов), `delivery_automation_layer` (поиск последнего слоя автоматизации).
|
||||
повторов), `delivery_automation_layer` (поиск последнего слоя автоматизации),
|
||||
`delivery_pending` (очередь свёртки).
|
||||
|
||||
`delivery_pending` **частичный** — только строки со статусом `pending`. Таблица
|
||||
и есть очередь фоновой свёртки: воркер выбирает неразобранные доставки в
|
||||
порядке журнала чаще, чем раз в минуту. В установившемся режиме в индексе
|
||||
ноль-одна строка, тогда как полный индекс по `parse_status` хранил бы всю
|
||||
историю ради выборки из одной.
|
||||
|
||||
## `bucket` — часовой объект точек
|
||||
|
||||
|
||||
+2
-2
@@ -12,8 +12,8 @@
|
||||
## Ближайшая цель
|
||||
|
||||
Метрики разбираются и ложатся в часовые объекты: тела перестали быть
|
||||
недифференцированной кучей. Блокеры, накопившиеся из ревью, разобраны — их в
|
||||
беклоге ноль.
|
||||
недифференцированной кучей. Приём отвечает `200`, не дожидаясь свёртки: её ведёт
|
||||
фоновый воркер, для которого очередью служит сама таблица доставок.
|
||||
|
||||
**`reindex` сделан**: журнал проигрывается в свежую витрину, отпечатки
|
||||
сравниваются, повторный прогон ничего не меняет. Доставки, числящиеся `pending`
|
||||
|
||||
Reference in New Issue
Block a user