- условие самообновления — доменный предикат store.State.IsObservable() вместо фазы catched; один поллер на поверхность, интервалы 5 с и 15 с - отказ тика отвечает 200 и самозавершающимся фрагментом с корневым id цели вместо 404/500, который htmx не свопит - заведён ADR-2026-08-10-observability-is-not-terminality, переписан раздел «Живой поллинг» в конвенции веб-UI
19 KiB
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 с.