web-ui: карточка и страница обновляются, пока задачу может двигать фон
- условие самообновления — доменный предикат store.State.IsObservable() вместо фазы catched; один поллер на поверхность, интервалы 5 с и 15 с - отказ тика отвечает 200 и самозавершающимся фрагментом с корневым id цели вместо 404/500, который htmx не свопит - заведён ADR-2026-08-10-observability-is-not-terminality, переписан раздел «Живой поллинг» в конвенции веб-UI
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
## Context
|
||||
|
||||
Живое обновление веб-UI собрано из трёх независимых поллеров, каждый привязан к
|
||||
своей фазе или региону:
|
||||
|
||||
- карточка списка опрашивает себя, пока `SelfPoll` — а это `d.State ==
|
||||
StateCatched` (`internal/httpapi/httpapi.go:664`, `card.html:2`);
|
||||
- вложенный блок прогресса опрашивает себя, пока `Active` — а это `d.State ==
|
||||
StateDownloading` (`internal/httpapi/live.go:88`, `progress.html:1`);
|
||||
- страница `/download/{id}` повторяет первое правило
|
||||
(`internal/httpapi/download.go:116`, `download_main.html:2`), а внутри неё
|
||||
секция «Раздача» опрашивает себя сама (`seeding.html:1`).
|
||||
|
||||
Ни одно из правил не покрывает выход из `downloading`, поэтому дальше задача
|
||||
живёт на экране в прошлом. Домен предикат уже даёт: `State.IsTerminal()`
|
||||
(`internal/store/download.go:63`) со своим единым перечнем терминальных
|
||||
состояний — заводить второй перечень в транспорте нельзя.
|
||||
|
||||
Цена тика у двух поверхностей разная, и это главное ограничение дизайна:
|
||||
|
||||
- фрагмент карточки — `GetDownload` плюс чтение in-memory снимка воркера;
|
||||
- страница загрузки — `Reviewer.ReviewData`, а он на каждом вызове строит
|
||||
предпросмотр раскладки через `layout.BuildLinks`
|
||||
(`internal/worker/review.go:1096`), то есть ходит в файловую систему.
|
||||
|
||||
Ещё одно свойство, из которого растут решения Р2 и Р5: `#seeding-{id}` лежит
|
||||
**внутри** `#download-main` (`download_main.html:61`, корень закрыт на строке
|
||||
129), а страница свопает этот корень целиком.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- смена состояния становится видимой без перезагрузки страницы, кто бы её ни
|
||||
сделал — воркер, веб-UI, Telegram или фоновая сверка;
|
||||
- обновление само прекращается, когда состояние менять больше некому;
|
||||
- число фоновых запросов на открытую страницу известно и обосновано.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- переход на SSE — отдельная задача `sse-live-updates`, и этот change её не
|
||||
приближает и не отменяет;
|
||||
- изменение состава живой телеметрии и `/api/**`;
|
||||
- обновление списка **целиком** (появление новых задач, изменение порядка и
|
||||
групп) — сегодня его нет, и эта задача его не заводит;
|
||||
- экран `/review/{id}`: он сохраняет фазовое самообновление, заказанное спекой
|
||||
`review` («пока загрузка в `recognizing`»), и приводится к общему правилу
|
||||
отдельной задачей.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Р1. Условие обновления — наблюдаемость, предикат общий с доменом
|
||||
|
||||
`SelfPoll` в обоих представлениях перестаёт зависеть от фазы. Наблюдаема
|
||||
задача, состояние которой ещё может измениться без участия этого браузера.
|
||||
|
||||
Одного `!IsTerminal()` для этого мало, и это выяснилось на ревью дизайна.
|
||||
Терминальность в проекте значит «не активна», а не «навсегда»: фоновая сверка
|
||||
двигает часть терминальных сама — `ListRecoverable`
|
||||
(`internal/store/download.go:606`) возвращает в поток `failed`/`stuck` с кодами
|
||||
`magnet_timeout` и `stalled`, а `desyncStates` (`internal/worker/reconcile.go:22`)
|
||||
переоценивает `done`, `target_missing` и `orphaned`. Карточка, застывшая по
|
||||
`IsTerminal`, показывала бы «Ошибка» у задачи, которая уже качается, — ровно тот
|
||||
дефект, ради которого затеян change.
|
||||
|
||||
**Решено на чекпоинте:** наблюдаемы все нетерминальные плюс `failed`,
|
||||
`target_missing`, `orphaned` — те, кого сверка возвращает в поток сама.
|
||||
Замолкают `done`, `cancelled`, `reverted`, `deleted`.
|
||||
|
||||
`done` в этот перечень не входит, хотя формально его тоже переоценивает сверка:
|
||||
переход `done → target_missing`/`orphaned` означает, что файлы удалили руками
|
||||
мимо сервиса, — событие редкое, а карточек `done` в списке больше всех. Платить
|
||||
за редкий случай постоянным фоновым запросом на каждую разложенную задачу
|
||||
дороже, чем показать её новое состояние при следующем заходе. Это принятое
|
||||
ограничение, а не упущение.
|
||||
|
||||
Предикат живёт **в домене**, рядом с `IsTerminal`, а не в `httpapi`: второй
|
||||
перечень состояний в транспорте разъедется на первом же новом состоянии.
|
||||
|
||||
Побочно это чинит и то, чего задача не заказывала: `stuck` и `deferred`
|
||||
нетерминальны, и их карточки тоже перестают застревать.
|
||||
|
||||
**Рассмотрено и отвергнуто:** «поллить, пока задача в активной группе списка» —
|
||||
группа считается из того же `IsTerminal`, то есть это то же условие, названное
|
||||
через представление, а не через домен.
|
||||
|
||||
### Р2. Один источник обновления на поверхность
|
||||
|
||||
Правило общее для карточки и для страницы: вложенные живые регионы своего опроса
|
||||
не ведут.
|
||||
|
||||
- в карточке блок прогресса (`progress.html`) остаётся вложенной разметкой без
|
||||
своего `hx-get`;
|
||||
- на странице секция «Раздача» (`seeding.html`) — тоже.
|
||||
|
||||
Основание фактическое, а не эстетическое. Своп корня меняет `outerHTML` целиком
|
||||
и уносит вложенный узел вместе с его таймером: два опроса на одну поверхность
|
||||
опрашивают одно и то же дважды, подменяют разметку друг друга, а тик корня,
|
||||
попавший в незавершённый запрос региона, роняет его ответ в никуда. До этого
|
||||
change конфликта не было только потому, что поверхности поллились в фазах, где
|
||||
вложенных регионов не существует (`catched` — ни прогресса, ни раздачи).
|
||||
|
||||
Цена названа прямо: цифры сидирования обновлялись раз в 3 с, станут обновляться с
|
||||
тиком страницы. Рейтинг и число пиров — не те величины, которым нужна
|
||||
трёхсекундная свежесть; прогресс качания, которому она нужна, едет с быстрым
|
||||
интервалом карточки.
|
||||
|
||||
**Рассмотрено и отвергнуто:** оставить регионам их опрос, а корень свопать
|
||||
частями (`hx-select` по кускам) — это заводит вторую механику свопа ради
|
||||
сохранения того, что и так не нужно с трёхсекундной частотой.
|
||||
|
||||
### Р3. Способ доставки — самополлинг фрагмента, а не сигнал из прогресса
|
||||
|
||||
Вариант «фрагмент прогресса, заметив уход из `downloading`, просит браузер
|
||||
обновить карточку» (`HX-Trigger` или `hx-swap-oob`) дешевле по запросам, но
|
||||
покрывает ровно один переход — тот, у которого был поллер. Переходы
|
||||
`review → linking → done`, сделанные из Telegram, остались бы невидимыми, а
|
||||
именно они дают самое долгое расхождение: задача стоит в ревью часами.
|
||||
|
||||
Вариант «поллить список одним запросом целиком» дал бы заодно появление новых
|
||||
задач, но перерисовывал бы всю страницу, ломая фильтр, поиск и прокрутку, —
|
||||
спека `live-status` это прямо запрещает. Это направление принадлежит SSE-задаче.
|
||||
|
||||
Следствие принятого варианта, названное сценарием спеки: карточка, дошедшая до
|
||||
конца на глазах у смотрящего, остаётся на своём месте в прежней группе списка —
|
||||
группы и фильтр считаются на рендере страницы и пересчитываются навигацией.
|
||||
|
||||
### Р4. Две частоты, потому что цена тика разная
|
||||
|
||||
Интервал зависит от того, несёт ли **поверхность** блок живых цифр качания:
|
||||
|
||||
- **быстрый** — карточка задачи в `downloading`: цифры меняются непрерывно, и
|
||||
это единственное место, где реже значит хуже;
|
||||
- **медленный** — все прочие наблюдаемые поверхности, включая **страницу
|
||||
`/download/{id}` в любом состоянии**: блока прогресса на ней нет вовсе
|
||||
(`grep progress web/templates/partials/download_main.html` пуст), а цифры
|
||||
раздачи трёхсекундной свежести не требуют.
|
||||
|
||||
Критерий именно «есть блок живых цифр», а не «состояние `downloading`»: на
|
||||
странице эти два признака расходятся, и по второму она перерисовывалась бы 20
|
||||
раз в минуту, не показывая ни одной изменившейся величины.
|
||||
|
||||
Арифметика, ради которой это и сделано. Открытая страница `/download/{id}` в
|
||||
`review` при быстром интервале звала бы `BuildLinks` 20 раз в минуту всё время,
|
||||
что вкладка открыта; при медленном — 4 раза. Для списка из N наблюдаемых
|
||||
карточек быстрый интервал везде дал бы `20 × N` запросов в минуту, разный —
|
||||
`20` за качающиеся и `4 × N` за остальные.
|
||||
|
||||
Уточнение после ревью кода: один тик карточки — это **два** обращения к
|
||||
хранилищу (`GetDownload` и `LayoutSizeByDownload`, оба по первичному ключу и
|
||||
индексу `idx_file_link_download`), а не одно. То есть открытый список даёт
|
||||
`8 × N` чтений в минуту вместо `4 × N`. Полный рендер страницы берёт размеры
|
||||
одним батчем, самообновление — по карточке: это цена того, что список не
|
||||
пересобирается целиком (см. Non-Goals). Замера под нагрузкой нет; порог, при
|
||||
котором это перестанет быть бесплатным, ищет задача `scale-100-downloads`.
|
||||
|
||||
**Решено на чекпоинте:** быстрый интервал — 5 с, вровень с
|
||||
`[worker].poll_interval` (`docs/database.md:201`), медленный — 15 с. Сегодняшние
|
||||
3 с обгоняют источник: воркер снимает телеметрию раз в 5 с, поэтому примерно два
|
||||
тика из пяти возвращают тот же кадр. Выравнивание убирает холостые запросы, а
|
||||
свежесть цифр не портит — она и так ограничена тиком воркера, что спека
|
||||
`live-status` прямо и требует («Свежесть не выше тика поллинга»).
|
||||
|
||||
Обе константы живут в одном месте кода рядом с представлениями и записываются в
|
||||
`docs/database.md` в таблицу настроек с числовым значением, там же — связь
|
||||
быстрого интервала с частотой опроса qBittorrent.
|
||||
|
||||
**Рассмотрено и отвергнуто:** единая частота — проще на один параметр, но делает
|
||||
открытую вкладку с ревью источником постоянных обращений к файловой системе;
|
||||
частота из конфига — настройка, которую никто не будет крутить, а
|
||||
`config.example.toml` и документацию она утяжелит.
|
||||
|
||||
### Р5. Фрагменты прогресса и раздачи остаются — и гасят разметку прошлой версии
|
||||
|
||||
Оба маршрута (`/fragments/downloads/{id}/progress`, `.../seeding`) после Р2
|
||||
остаются без потребителя в новой разметке. Удалить их сразу нельзя: htmx **не
|
||||
свопит** ответы 4xx/5xx и не снимает с узла `hx-trigger`, поэтому вкладка,
|
||||
открытая до деплоя, слала бы запросы на удалённый маршрут каждые 3 секунды до
|
||||
самого закрытия — молча для смотрящего и десятками тысяч строк в журнале
|
||||
доступа.
|
||||
|
||||
Оставленные маршруты отдают те же партиалы, которые после правки поллинга не
|
||||
несут, — то есть первый же тик старой вкладки гасит её собственный опрос.
|
||||
Уборка этих двух обработчиков — отдельная мелкая задача после деплоя; она
|
||||
уезжает в урожай ревью, а не остаётся обещанием в комментарии.
|
||||
|
||||
`buildProgress` и `buildSeeding` остаются в любом случае: ими собираются виды,
|
||||
вложенные в карточку и страницу.
|
||||
|
||||
### Р6. Самообновление не теряет полей полного рендера
|
||||
|
||||
`handleFragCard` сегодня зовёт `buildCardView` с `layoutSize = 0` — упрощение
|
||||
времени, когда фрагмент обслуживал только `catched`, где раскладки не бывает.
|
||||
После расширения тот же обработчик обслуживает `linking`, `deferred` и прочие
|
||||
состояния с уже разложенными файлами, и при отсутствии раздачи в снимке
|
||||
самообновление подменило бы показанный размер прочерком.
|
||||
|
||||
Фрагмент читает размер раскладки так же, как это делает своповый путь действия
|
||||
(`renderCardFragment` → `LayoutSizeByDownload`).
|
||||
|
||||
### Р7. Отказ тика самозавершается
|
||||
|
||||
Тик, не сумевший прочитать задачу (записи нет — например, её убрала уборка; или
|
||||
отказало хранилище), отвечает `200` и фрагментом без `hx-*`. Иначе htmx не
|
||||
свопит ответ, поверхность остаётся прежней навсегда, а опрос продолжается: при
|
||||
затяжном отказе хранилища одна открытая вкладка даёт `4 × N` записей в журнале в
|
||||
минуту. Форма ответа согласована с уже записанной конвенцией для htmx-пути
|
||||
действий (`docs/conventions/web-ui.md`).
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Своп карточки раз в интервал попадает в момент, когда человек ведёт мышь к
|
||||
кнопке** → поведение уже существует у карточек в `catched`; кнопки — обычные
|
||||
формы, потеря фокуса восстанавливается повторным наведением. Отдельного
|
||||
гашения свопа при наведении не делаем: заметная механика ради редкого случая.
|
||||
- **Сообщение об ошибке действия (`ActionError`) живёт до следующего тика** →
|
||||
сегодня оно живёт до любого следующего свопа, и в `catched` уже так. Отдельно
|
||||
не удерживаем: место для устойчивого объяснения — страница загрузки.
|
||||
- **Открытая на ночь вкладка держит опрос, пока есть хоть одна наблюдаемая
|
||||
задача** → ограничено медленным интервалом и прекращается само. Полный отказ
|
||||
от фонового опроса — предмет SSE-задачи.
|
||||
- **Цифры раздачи стали обновляться реже** → принято осознанно в Р2; величины
|
||||
медленные, а альтернатива — вложенный поллер внутри свопаемого корня.
|
||||
|
||||
## Open Questions
|
||||
|
||||
Нет. Обе развилки решены на чекпоинте и записаны в Р1 и Р4: наблюдаемы
|
||||
нетерминальные плюс `failed`/`target_missing`/`orphaned`; интервалы — 5 с и 15 с.
|
||||
Reference in New Issue
Block a user