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,56 @@
|
||||
# Наблюдаемость поверхности не выводится из терминальности задачи
|
||||
|
||||
- **Дата:** 2026-08-10
|
||||
- **Источник:** openspec/changes/archive/2026-08-10-card-live-refresh/design.md
|
||||
|
||||
## Решение
|
||||
|
||||
Веб-UI обновляет себя, пока задача **наблюдаема** — то есть её состояние ещё
|
||||
может измениться без участия человека, — а не пока она нетерминальна.
|
||||
Предикат `store.State.IsObservable()` живёт в домене рядом с `IsTerminal()` и
|
||||
даёт: все нетерминальные плюс `failed`, `target_missing`, `orphaned`. Замолкают
|
||||
`done`, `cancelled`, `reverted`, `deleted`.
|
||||
|
||||
## Почему
|
||||
|
||||
Очевидный предикат — «обновляемся, пока задача не терминальна» — оказался
|
||||
неверным, и это выяснилось на ревью дизайна, до кода. Цитата из источника:
|
||||
|
||||
> Терминальность в проекте значит «не активна», а не «навсегда»: фоновая сверка
|
||||
> двигает часть терминальных сама — `ListRecoverable` возвращает в поток
|
||||
> `failed`/`stuck` с кодами `magnet_timeout` и `stalled`, а `desyncStates`
|
||||
> переоценивает `done`, `target_missing` и `orphaned`. Карточка, застывшая по
|
||||
> `IsTerminal`, показывала бы «Ошибка» у задачи, которая уже качается, — ровно
|
||||
> тот дефект, ради которого затеян change.
|
||||
|
||||
`done` в перечень наблюдаемых не вошёл, и это отдельное решение с ценой:
|
||||
|
||||
> Переход `done → target_missing`/`orphaned` означает, что файлы удалили руками
|
||||
> мимо сервиса, — событие редкое, а карточек `done` в списке больше всех.
|
||||
> Платить за редкий случай постоянным фоновым запросом на каждую разложенную
|
||||
> задачу дороже, чем показать её новое состояние при следующем заходе.
|
||||
|
||||
## Рассмотренные варианты
|
||||
|
||||
- **Наблюдать только нетерминальные** (как задумывалось изначально) — проще
|
||||
всего и не заводит второго предиката. Отвергнут: задача, оживлённая сверкой из
|
||||
`failed`, висела бы на экране с надписью «Ошибка» до перезагрузки, причём
|
||||
соседние карточки при этом обновлялись бы — застывшая читалась бы как
|
||||
достоверная.
|
||||
- **Наблюдать всё, терминальные — редким тиком** — снимает вопрос целиком.
|
||||
Отвергнут: список из сотни разложенных задач слал бы пустые запросы вечно, а
|
||||
критерий приёмки «завершённая карточка себя не опрашивает» пришлось бы
|
||||
отменить.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` смена состояния становится видимой независимо от того, кто её сделал:
|
||||
воркер, веб-UI, Telegram или фоновая сверка.
|
||||
- `+` условие обновления выражено одним доменным предикатом; второго перечня
|
||||
состояний в транспорте нет, и завести его нельзя не заметив.
|
||||
- `−` в домене стало два перечня состояний вместо одного, и второй выведен из
|
||||
поведения воркера (`desyncStates`, `ListRecoverable`) вручную. Расширение
|
||||
сверки новым состоянием молча вернёт застывшую карточку — связки, которая бы
|
||||
это ловила, нет.
|
||||
- `−` карточка `failed`, `target_missing` или `orphaned` опрашивает сервер, пока
|
||||
открыта вкладка: эти состояния живут долго и копятся (срока хранения нет).
|
||||
@@ -42,6 +42,7 @@
|
||||
|
||||
| Дата | Запись | Статус |
|
||||
| --- | --- | --- |
|
||||
| 2026-08-10 | [Наблюдаемость поверхности не выводится из терминальности задачи](ADR-2026-08-10-observability-is-not-terminality.md) | — |
|
||||
| 2026-08-10 | [Причина, по которой человек не видит плана, считается на показе, а не читается из состояния](ADR-2026-08-10-reason-computed-on-read.md) | — |
|
||||
| 2026-08-10 | [Значение метабазы чистится на каждой точке входа в план, три санитайзера не сводятся в один](ADR-2026-08-10-sanitize-at-every-entry.md) | — |
|
||||
| 2026-08-07 | [Локаль TVDB читается из ответа поиска, а не передаётся в запрос](ADR-2026-08-07-tvdb-locale-reads-response.md) | — |
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
| Хардлинки и удаление своих ссылок | `internal/layout` — единственное место, которое пишет в файловую систему библиотеки |
|
||||
| Построение и проверка целевого пути | `layout.BuildLinks` — единственная сборка пути; там же обе проверки, и порядок значим: нахождение под корнем библиотеки, затем длина компонента. Отсюда же строятся оба предпросмотра ревью, поэтому показанное и применённое совпадают устройством, а не договорённостью |
|
||||
| Причина, по которой человек не видит плана | считается **на показе** (`worker.ReviewData.PreviewError`) и предпочитается записанной в состоянии: записанной может не быть вовсе, а после смены источника она уже про другой план — [ADR-2026-08-10-reason-computed-on-read](adr/ADR-2026-08-10-reason-computed-on-read.md) |
|
||||
| Условие самообновления веб-UI | `store.State.IsObservable()` — «состояние ещё может измениться без человека»; транспорт своего перечня состояний не заводит, а поверхность (карточка списка, страница загрузки) держит **ровно один** поллер на обновляемый корень — [ADR-2026-08-10-observability-is-not-terminality](adr/ADR-2026-08-10-observability-is-not-terminality.md), правило разметки — [conventions/web-ui.md](conventions/web-ui.md) |
|
||||
| Трансляция доменной ошибки в код ответа | внешняя граница транспорта (`httpapi`, `tgbot`); правило — [conventions/errors.md](conventions/errors.md) |
|
||||
| Логирующий чекпоинт | доменная граница, один на операцию; правило — [conventions/logging.md](conventions/logging.md) |
|
||||
| Настройки | один TOML-файл, валидируется на старте; образец `config.example.toml` — источник истины по полям |
|
||||
@@ -185,7 +186,7 @@ Jellyfin указывают на `movies`/`series`, а не на корень
|
||||
| Масштаб | ориентир 100/1000 загрузок не зафиксирован, узкие места SQLite, воркера и поллинга не измерены | `scale-100-downloads` |
|
||||
| Ретеншен | терминальные задачи и сырые ответы LLM копятся вечно, авточистки нет | `db-retention-cleanup` |
|
||||
| Бекап | бекапить `/data` требуется, а стратегия и ротация не описаны | `sqlite-backup` |
|
||||
| Наблюдаемость | healthcheck проверяет только сам сервис; метрик и алертинга нет, отказ виден по застрявшей задаче | `deep-healthcheck-dependencies` |
|
||||
| Метрики и алертинг | healthcheck проверяет только сам сервис; метрик и алертинга нет, отказ виден по застрявшей задаче | `deep-healthcheck-dependencies` |
|
||||
| Идентичность раздачи | split v1/v2-хеши не связаны, паре `xt` из магнета доверяем | `infohash-identity-integrity` |
|
||||
| Расход внешних лимитов | кэша ответов метабаз нет, повтор распознавания бьёт провайдера заново | `metadata-cache` |
|
||||
| История переходов | хранится только текущее состояние, «как сюда попали» восстанавливается по логам | `download-transition-history` |
|
||||
|
||||
+35
-14
@@ -103,27 +103,48 @@ htmx по умолчанию **не свопит DOM на ответы 4xx/5xx**
|
||||
## Живой поллинг
|
||||
|
||||
Паттерн живого обновления: фрагмент-эндпоинт под `/fragments/...` + в разметке
|
||||
`hx-get` + `hx-trigger="every Ns"` + `hx-swap="outerHTML"` (эталон —
|
||||
`progress`/`seeding`, `handleFragProgress`/`handleFragSeeding`):
|
||||
`hx-get` + `hx-trigger="every Ns"` + `hx-swap="outerHTML"`. Эталон — карточка
|
||||
списка (`card`, `handleFragCard`):
|
||||
|
||||
```html
|
||||
{{define "progress"}}<div id="dl-live-{{.ID}}"
|
||||
{{if .Active}} hx-get="/fragments/downloads/{{.ID}}/progress"
|
||||
hx-trigger="every 3s" hx-swap="outerHTML"{{end}}>
|
||||
{{define "card"}}<article class="card" id="card-{{.ID}}"
|
||||
{{if .SelfPoll}} hx-get="/fragments/downloads/{{.ID}}/card"
|
||||
hx-trigger="every {{.PollEvery}}" hx-swap="outerHTML"{{end}}>
|
||||
...
|
||||
</div>{{end}}
|
||||
</article>{{end}}
|
||||
```
|
||||
|
||||
- **Поллер самозавершается.** Когда состояние выходит из «живого» (`Active`
|
||||
ложно, торрент не сидирует), фрагмент возвращается **без `hx-*`** — htmx
|
||||
больше не опрашивает. Условие «живости» ведёт store-состояние (`downloading`
|
||||
для прогресса), а не qBittorrent.
|
||||
- **Один поллер на обновляемый корень.** Опрашивает себя корень поверхности
|
||||
(карточка списка, главная область страницы), а вложенные живые регионы —
|
||||
прогресс качания, секция раздачи — своего `hx-get` **не несут**: своп корня
|
||||
уносит их вместе с таймером, и два опроса подменяли бы разметку друг друга.
|
||||
Живые цифры приезжают вместе с корнем.
|
||||
- **Поллер самозавершается.** Опрос ведётся, пока предмет может измениться без
|
||||
участия браузера; перестал — фрагмент возвращается **без `hx-*`**, и htmx
|
||||
больше не опрашивает. Условие определяется store-состоянием
|
||||
(`State.IsObservable()`), а не qBittorrent.
|
||||
- **Отказ тика тоже самозавершается.** Не сумев прочитать задачу, тик отвечает
|
||||
`200` и фрагментом с объяснением **без `hx-*`**: htmx не свопит `4xx/5xx`,
|
||||
поэтому статус ошибки оставил бы поверхность навсегда прежней, а опрос —
|
||||
бесконечным. Фрагмент отказа обязан нести корневой `id` того узла, который он
|
||||
собой заменяет (см. инвариант выше), иначе `hx-swap` подменит не тот узел.
|
||||
- **Уровень лога у тика — `WARN`.** У повторяющегося опроса есть штатный ретрай;
|
||||
`ERROR` оставляем разовому действию человека (см. [logging.md](logging.md)).
|
||||
- **`outerHTML`-своп всего фрагмента** удаляет старый узел вместе с его
|
||||
поллером и htmx `process`-инициализирует новый — двойного опроса нет **при
|
||||
условии совпадения корневого `id`** (см. инвариант выше).
|
||||
- Данные тика — из in-memory снимка воркера (`LiveStatus.Live(infohash)`), без
|
||||
БД/сети на каждый тик; узкий контракт `LiveStatus` не зависит от способа
|
||||
доставки (поллинг сейчас, путь к SSE оставлен изолированным).
|
||||
условии совпадения корневого `id`** (см. инвариант выше). Эфемерное состояние
|
||||
разметки своп не переживает: то, что должно пережить тик (раскрытый
|
||||
`<details>`), помечается `hx-preserve`.
|
||||
- **Частота — по цене тика, и она названа числом в
|
||||
[database.md](../database.md).** Поверхность с живыми цифрами качания
|
||||
обновляется чаще (`pollFast`, вровень с частотой опроса qBittorrent — быстрее
|
||||
источника опрашивать бессмысленно), прочие наблюдаемые — реже (`pollSlow`).
|
||||
- **Тик ходит в БД, и это цена решения.** Живые цифры берутся из in-memory
|
||||
снимка воркера (`LiveStatus.Live(infohash)`), но состояние и размер раскладки
|
||||
тик читает из хранилища, а тик страницы загрузки ещё и считает предпросмотр
|
||||
раскладки с обходом ФС — отсюда и разные интервалы. Узкий контракт
|
||||
`LiveStatus` при этом не зависит от способа доставки (поллинг сейчас, путь к
|
||||
SSE оставлен изолированным).
|
||||
- **Инвариант: браузер не опрашивает qBittorrent напрямую** — только свой сервер,
|
||||
который читает снимок. Поллинг статуса UI логируем на `DEBUG` (рутинно-частое,
|
||||
см. [logging.md](logging.md)).
|
||||
|
||||
@@ -212,6 +212,8 @@ erDiagram
|
||||
| Константа | Значение | Что означает |
|
||||
| --- | --- | --- |
|
||||
| `ingest.MaxTorrentSize` | `8 MiB` | предел размера принимаемого `.torrent`; проверяется **до** разбора, поэтому bencode-аллокации на эту величину не масштабируются (см. [research/torrent-bencode-limits.md](research/torrent-bencode-limits.md)) |
|
||||
| `httpapi.pollFast` | `5s` | интервал самообновления поверхности с живыми цифрами качания (карточка в `downloading`). Держится вровень с `[worker].poll_interval`: снимок телеметрии обновляется тиком воркера, и опрос чаще возвращает тот же снимок. Меняется `poll_interval` — меняется и эта константа |
|
||||
| `httpapi.pollSlow` | `15s` | интервал самообновления прочих наблюдаемых поверхностей: карточек вне `downloading` и страницы `/download/{id}` в любом состоянии. Тик страницы считает предпросмотр раскладки и ходит в ФС, поэтому частота у него ниже |
|
||||
| `layout.maxComponentBytes` | `255` байт | предел длины компонента целевого пути (`NAME_MAX` у ext4/xfs/btrfs); меряется в байтах UTF-8, проверяется **до** первой операции с ФС, отказ уводит задачу в `review` с кодом `name_too_long`. У ядра не выясняется; на ФС с меньшим пределом остаётся отказ ядра — лечение правкой константы, а не настройкой |
|
||||
|
||||
**Ретеншена нет ни у одной таблицы**, лимита на размер тела ответа LLM нет,
|
||||
|
||||
@@ -325,6 +325,25 @@ Go-сервиса и что здесь уже проскакивало. Устр
|
||||
случаи до этой даты не восстанавливались — восстановленная постфактум причина
|
||||
непоймания недостоверна, а именно она и нужна.
|
||||
|
||||
## 2026-08-10 — тест остался зелёным навсегда, потому что проверял снятый атрибут [пойман]
|
||||
|
||||
- **Где:** `internal/httpapi/live_test.go` — `TestFragProgressStopsWhenNotDownloading`
|
||||
- **Симптом:** проход `autotests` на ревью кода change `card-live-refresh` заметил,
|
||||
что тест «фрагмент отдаётся без атрибутов поллинга» больше не может упасть
|
||||
- **Причина:** change снял `hx-get`/`hx-trigger` с партиала `progress`
|
||||
**безусловно**, а тест утверждал их отсутствие только для завершённой задачи.
|
||||
Утверждение стало истинным при любом входе — тест перестал проверять что-либо,
|
||||
оставаясь в дереве как доказательство поведения
|
||||
- **Чем воспроизведён:** `git diff` шаблона против тела теста; проверка инверсией
|
||||
невозможна по построению — сломать реализацию так, чтобы тест покраснел, нечем
|
||||
- **Что меняем:** ничего в гейте. `diff-coverage` меряет **исполнение**, а не
|
||||
проверку, и такой класс не видит по устройству — 24/24 строк были покрыты при
|
||||
зелёном тесте-пустышке. Ловится либо мутационным прогоном (в гейт не заводим:
|
||||
цена выше пользы на нынешнем объёме), либо тем же вопросом темы `autotests`
|
||||
(«есть ли тест, который упал бы без этой правки») — он и сработал. Тест
|
||||
переформулирован на то, что теперь является предметом: вне `downloading` блок
|
||||
живых цифр не рисуется вовсе
|
||||
|
||||
## 2026-08-10 — проверка встала в общую точку и погасила кнопку, ничего не объяснив [пойман]
|
||||
|
||||
- **Где:** `internal/layout/layout.go` — `BuildLinks`; `internal/worker/review.go`
|
||||
|
||||
Reference in New Issue
Block a user