Files
jellybit/openspec/changes/archive/2026-08-10-card-live-refresh/design.md
T
av a5d873b62d web-ui: карточка и страница обновляются, пока задачу может двигать фон
- условие самообновления — доменный предикат store.State.IsObservable() вместо
  фазы catched; один поллер на поверхность, интервалы 5 с и 15 с
- отказ тика отвечает 200 и самозавершающимся фрагментом с корневым id цели
  вместо 404/500, который htmx не свопит
- заведён ADR-2026-08-10-observability-is-not-terminality, переписан раздел
  «Живой поллинг» в конвенции веб-UI
2026-08-10 14:02:38 +03:00

229 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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 с.