ingest: закрыты мелочи приёма — вырожденное имя, контракт Result, корреляция add

- имя раздачи нормализуется на границе разбора: вырожденное `-`
  (metainfo.NoName) даёт пустое имя, пробельное схлопывается — сентинел больше
  не доходит ни до контекста распознавания, ни до source_ref, ни до подсказки
  вывода имени
- контракт «на любом пути ошибки приёма результат нулевой» объявлен в ingest и
  удерживается структурно; три транспорта перестали обещать идентификатор,
  которого нет, и коррелируют отказ по request_id
- scoped-логгер загрузки ставится до вызова внешнего сервиса в семи командах
  воркера — записи об отказе qBittorrent и метабаз получили download_id
  и infohash; граница разбора bencode записана в docs/research
This commit is contained in:
av
2026-08-06 18:20:12 +03:00
parent 52615e4e49
commit d081ef1d30
30 changed files with 2190 additions and 63 deletions
+7 -5
View File
@@ -432,7 +432,9 @@ func (s *server) handleUIAdd(w http.ResponseWriter, r *http.Request) {
res, err := s.deps.Ingestor.Ingest(r.Context(), req)
if err != nil {
redirectErr(w, r, userErr(r, err, res.DownloadID))
// Нулевой Result на любом пути ошибки — контракт ingest.Ingest;
// корреляционный ключ веб-формы, как и REST, — request_id.
redirectErr(w, r, userErr(r, err, ""))
return
}
if res.Deduplicated {
@@ -585,10 +587,10 @@ func (s *server) handleAPIAdd(w http.ResponseWriter, r *http.Request) {
}
res, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{Source: req.Source, Context: req.Context})
if err != nil {
// res.DownloadID непуст, если сбой после создания задачи (напр. qbit) —
// тогда коррелируем по download_id, иначе (ранний разбор источника) по
// request_id.
s.apiErr(w, r, err, res.DownloadID)
// Приём на любом пути ошибки возвращает нулевой Result (контракт
// ingest.Ingest) — идентификатора загрузки тут нет и быть не может,
// коррелируем по request_id.
s.apiErr(w, r, err, "")
return
}
status := http.StatusCreated
+50
View File
@@ -2,6 +2,9 @@ package httpapi_test
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"mime/multipart"
"net/http"
"net/url"
@@ -85,3 +88,50 @@ func TestUIAddUrlencoded(t *testing.T) {
t.Errorf("urlencoded source не проброшен: %q", ing.lastReq.Source)
}
}
// Отказ приёма на HTTP-границе: идентификатора загрузки нет (контракт
// ingest.Ingest — нулевой Result на любом пути ошибки), поэтому корреляционным
// ключом остаётся request_id запроса. Проверяются оба HTTP-транспорта: REST
// отдаёт ключ полем тела, веб-форма — текстом флеш-сообщения в редиректе.
func TestIngestErrorCorrelatesByRequestID(t *testing.T) {
t.Run("REST", func(t *testing.T) {
ing := &fakeIngestor{err: fmt.Errorf("ingest: create download: %w", errors.New("boom"))}
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
resp, err := http.Post(srv.URL+"/api/downloads", "application/json",
strings.NewReader(`{"source":"magnet:?xt=urn:btih:abc"}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var body map[string]any
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if s, _ := body["request_id"].(string); s == "" {
t.Errorf("в теле отказа нет request_id: %v", body)
}
if _, ok := body["download_id"]; ok {
t.Errorf("в теле отказа обещан download_id: %v", body)
}
})
t.Run("веб-форма", func(t *testing.T) {
ing := &fakeIngestor{err: fmt.Errorf("ingest: create download: %w", errors.New("boom"))}
srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads",
url.Values{"source": {"magnet:?xt=urn:btih:abc"}})
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
loc := resp.Header.Get("Location")
if !strings.Contains(loc, "request_id%3D") && !strings.Contains(loc, "request_id=") {
t.Errorf("в сообщении отказа нет request_id: %q", loc)
}
if strings.Contains(loc, "download_id") {
t.Errorf("в сообщении отказа обещан download_id: %q", loc)
}
})
}
+25 -6
View File
@@ -66,7 +66,9 @@ type Request struct {
Context string // подсказка для распознавания (опц.)
}
// Result — итог приёма.
// Result — итог приёма. При ненулевой ошибке Ingest возвращает НУЛЕВОЙ Result:
// идентификатор загрузки, хеши, состояние и признак дедупликации не
// публикуются (см. Ingest).
type Result struct {
DownloadID string
Infohashes []string // все хеши источника (гибридный magnet: v1 и v2, v1 первым)
@@ -78,7 +80,24 @@ type Result struct {
// полей ссылки, дедуплицирует по активной задаче, иначе сохраняет загрузку в
// `catched` и сразу возвращает результат. Добавление в qBittorrent и вывод
// имени выполняет worker (см. download-tracking).
func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
//
// Контракт: на ЛЮБОМ пути ошибки возвращается нулевой Result. Приём не создаёт
// наблюдаемых последствий раньше, чем способен вернуть успех, а всё, что может
// отказать после заведения загрузки, делает worker. Транспорты на это
// опираются и не обещают идентификатора, которого нет: HTTP коррелирует отказ
// по request_id, Telegram — ключа не даёт (см. ingest-спеку, требование
// «Результат приёма при ошибке пуст»).
//
// Гарантия структурная — обнуление в одном defer, а не аккуратность каждой
// ветки возврата: перечень веток растёт, и именно расхождение перечня с
// комментариями транспортов породило исходный дефект.
func (s *Service) Ingest(ctx context.Context, req Request) (res Result, err error) {
defer func() {
if err != nil {
res = Result{}
}
}()
src, err := s.parse(req)
if err != nil {
// Невалидный источник — норма (адресат не команда, а пользователь, и он
@@ -179,10 +198,10 @@ func (s *Service) parse(req Request) (parsedSource, error) {
}
// SourceRef — человекочитаемый референс (имя раздачи), НЕ адрес
// добавления: torrent добавляется байтами (см. worker), не по SourceRef.
// Фолбек на имя файла, если у раздачи нет содержательного имени
// (пустое или NoName-сентинел "-").
ref := strings.TrimSpace(info.DisplayName)
if ref == "" || ref == "-" {
// Фолбек на имя файла, если у раздачи нет содержательного имени:
// вырожденное значение отбросил разборщик, здесь остаётся пустота.
ref := info.DisplayName
if ref == "" {
ref = strings.TrimSpace(req.TorrentName)
}
return parsedSource{
+38
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"log/slog"
"reflect"
"strings"
"testing"
@@ -24,13 +25,22 @@ type fakeStore struct {
upgradeID string // downloadID последнего вызова UpgradeCatchedMagnetToTorrent
upgradeBlob []byte // байты, переданные в апгрейд
upgradeUp bool // что вернуть из UpgradeCatchedMagnetToTorrent
lookupErr error // отказ хранилища на дедуп-чеке
createErr error // отказ хранилища на заведении загрузки
}
func (f *fakeStore) FindReingestBlockingByInfohash(_ context.Context, _ ...string) (*store.Download, error) {
if f.lookupErr != nil {
return nil, f.lookupErr
}
return f.active, nil
}
func (f *fakeStore) CreateDownloadIfNoActive(_ context.Context, d *store.Download, hashes []string, torrentBlob []byte) (*store.Download, error) {
if f.createErr != nil {
return nil, f.createErr
}
if f.active != nil {
return f.active, nil
}
@@ -300,3 +310,31 @@ func TestIngestRejectsNonMagnet(t *testing.T) {
t.Error("не должно быть записи задачи")
}
}
// Контракт приёма: на ЛЮБОМ пути ошибки транспорту возвращается НУЛЕВОЙ Result.
// Транспорты на это опираются и не обещают идентификатора, которого нет
// (см. ingest-спеку, «Результат приёма при ошибке пуст»). Сравниваем результат
// с нулевым значением ЦЕЛИКОМ, а не по полю DownloadID: следующая ветвь отказа
// может заполнить другое поле.
func TestIngestReturnsZeroResultOnEveryErrorPath(t *testing.T) {
boom := errors.New("boom")
for _, tc := range []struct {
name string
fs *fakeStore
req Request
}{
{"невалидный источник", &fakeStore{}, Request{Source: "не magnet и не torrent"}},
{"сбой хранилища на дедуп-чеке", &fakeStore{lookupErr: boom}, Request{Source: sampleMagnet}},
{"сбой хранилища на заведении", &fakeStore{createErr: boom}, Request{Source: sampleMagnet}},
} {
t.Run(tc.name, func(t *testing.T) {
res, err := newService(tc.fs).Ingest(context.Background(), tc.req)
if err == nil {
t.Fatal("ожидалась ошибка")
}
if !reflect.DeepEqual(res, Result{}) {
t.Errorf("Result = %+v, want нулевой", res)
}
})
}
}
+32 -20
View File
@@ -134,28 +134,40 @@ func TestIngestTorrentTooLarge(t *testing.T) {
}
}
// У раздачи без имени source_ref берётся из имени файла (фолбек).
// У раздачи без содержательного имени source_ref берётся из имени файла.
// Случая два, и они разные: раздача БЕЗ поля name (BestName() == "") и
// раздача, объявившая вырожденное `-` (metainfo.NoName) — второй нормализует
// разборщик, приём про него уже не знает.
func TestIngestTorrentNameFallback(t *testing.T) {
// Info без name → BestName() == "" → фолбек на TorrentName.
info := metainfo.Info{Name: "", Length: 1024, PieceLength: 512, Pieces: make([]byte, 40)}
infoBytes, err := bencode.Marshal(info)
if err != nil {
t.Fatalf("marshal: %v", err)
}
mi := metainfo.MetaInfo{InfoBytes: infoBytes, Announce: "http://t/ann"}
var buf bytes.Buffer
if err := mi.Write(&buf); err != nil {
t.Fatalf("write: %v", err)
}
for _, tc := range []struct {
name string
infoName string
}{
{"без поля name", ""},
{"вырожденное имя", metainfo.NoName},
} {
t.Run(tc.name, func(t *testing.T) {
info := metainfo.Info{Name: tc.infoName, Length: 1024, PieceLength: 512, Pieces: make([]byte, 40)}
infoBytes, err := bencode.Marshal(info)
if err != nil {
t.Fatalf("marshal: %v", err)
}
mi := metainfo.MetaInfo{InfoBytes: infoBytes, Announce: "http://t/ann"}
var buf bytes.Buffer
if err := mi.Write(&buf); err != nil {
t.Fatalf("write: %v", err)
}
fs := &fakeStore{}
_, err = newService(fs).Ingest(context.Background(),
Request{TorrentData: buf.Bytes(), TorrentName: "Fallback.Name.torrent"})
if err != nil {
t.Fatalf("Ingest: %v", err)
}
if len(fs.created) != 1 || fs.created[0].SourceRef != "Fallback.Name.torrent" {
t.Errorf("source_ref = %q, want фолбек на имя файла", fs.created[0].SourceRef)
fs := &fakeStore{}
_, err = newService(fs).Ingest(context.Background(),
Request{TorrentData: buf.Bytes(), TorrentName: "Fallback.Name.torrent"})
if err != nil {
t.Fatalf("Ingest: %v", err)
}
if len(fs.created) != 1 || fs.created[0].SourceRef != "Fallback.Name.torrent" {
t.Errorf("source_ref = %q, want фолбек на имя файла", fs.created[0].SourceRef)
}
})
}
}
+102
View File
@@ -2,10 +2,13 @@ package qbt
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.vakhrushev.me/av/jellybit/internal/logctx"
)
// fakeQBittorrent — минимальный стенд WebUI API: требует cookie SID, выдаёт
@@ -238,3 +241,102 @@ func TestLoginFailure(t *testing.T) {
t.Error("ожидалась ошибка логина")
}
}
// captureHandler собирает записи журнала для проверки их полей. Свой WithAttrs
// нужен обязательно: делегированный встроенному хендлеру вернул бы ЕГО, и
// логгер, собранный через With (а scoped-логгер загрузки собирается именно
// так), перестал бы захватываться.
type captureHandler struct {
attrs []slog.Attr
records *[]slog.Record
}
func (h *captureHandler) Enabled(context.Context, slog.Level) bool { return true }
func (h *captureHandler) Handle(_ context.Context, r slog.Record) error {
rec := r.Clone()
rec.AddAttrs(h.attrs...)
*h.records = append(*h.records, rec)
return nil
}
func (h *captureHandler) WithAttrs(as []slog.Attr) slog.Handler {
return &captureHandler{attrs: append(append([]slog.Attr{}, h.attrs...), as...), records: h.records}
}
func (h *captureHandler) WithGroup(string) slog.Handler { return h }
// recordFields сплющивает запись в map «ключ → значение как текст».
func recordFields(r slog.Record) map[string]string {
out := map[string]string{}
r.Attrs(func(a slog.Attr) bool {
out[a.Key] = a.Value.String()
return true
})
return out
}
// Отказ qBittorrent на добавление («Fails.») причины не несёт — единственное,
// что делает такую запись пригодной для разбора, это корреляция с загрузкой.
// Инфохэша клиент не знает и знать не должен (разбор источника — единая точка
// проекта): он берёт scoped-логгер из ctx. Негативная половина: magnet с
// passkey приватного трекера уходит через это же поле формы, и в журнал он
// попасть не должен (инвариант «секреты не попадают в логи»).
func TestAddFailureRecordCarriesScopeAndNoSecret(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v2/auth/login" {
http.SetCookie(w, &http.Cookie{Name: "SID", Value: "token", Path: "/"})
_, _ = w.Write([]byte("Ok."))
return
}
_, _ = w.Write([]byte("Fails."))
}))
defer srv.Close()
// Значение низкоэнтропийное намеренно: высокоэнтропийная фикстура «похожего
// на секрет» вида краснит gitleaks в pre-commit. Различимости хватает —
// тест ищет вхождение этой строки в полях записи.
const passkey = "passkey-must-not-reach-the-journal"
const magnetURL = "magnet:?xt=urn:btih:541adcff3b6dd5dba7088ea83317d9d6fac331d6" +
"&tr=http%3A%2F%2Ftracker.example%2Fann%3Fpasskey%3D" + passkey
var records []slog.Record
log := slog.New(&captureHandler{records: &records}).
With("capability", "review", "download_id", "01JB0000000000000000000000", "infohash",
"541adcff3b6dd5dba7088ea83317d9d6fac331d6")
ctx := logctx.With(context.Background(), log)
err := newClient(t, srv.URL).Add(ctx, AddRequest{URLs: []string{magnetURL}, Category: "jellybit"})
if err == nil {
t.Fatal("ожидался отказ добавления")
}
var failure *slog.Record
for i := range records {
if records[i].Message == "external call failed" {
failure = &records[i]
}
}
if failure == nil {
t.Fatalf("записи об отказе внешнего вызова нет, записей: %d", len(records))
}
f := recordFields(*failure)
if f["download_id"] == "" {
t.Errorf("в записи нет download_id из scoped-логгера: %v", f)
}
if f["infohash"] != "541adcff3b6dd5dba7088ea83317d9d6fac331d6" {
t.Errorf("infohash = %q, want хеш из scoped-логгера", f["infohash"])
}
if f["ext.operation"] != "torrents/add" {
t.Errorf("ext.operation = %q, want torrents/add", f["ext.operation"])
}
// Негативная половина: ни одно поле записи не несёт passkey.
for k, v := range f {
if strings.Contains(v, passkey) {
t.Errorf("passkey утёк в поле %q: %q", k, v)
}
}
if strings.Contains(failure.Message, passkey) {
t.Errorf("passkey утёк в сообщение записи")
}
}
+6 -4
View File
@@ -262,10 +262,12 @@ func (b *Bot) downloadFile(ctx context.Context, fileID string) ([]byte, error) {
func (b *Bot) ingestAndReply(ctx context.Context, chatID int64, req ingest.Request) {
res, err := b.ingestor.Ingest(ctx, req)
if err != nil {
// res.DownloadID непуст, если сбой после создания задачи (напр. qbit);
// при раннем разборе источника id ещё нет — даём дружелюбный текст без
// него (детали всё равно в логах на доменной границе).
b.send(chatID, opErr("Не удалось принять загрузку", res.DownloadID), nil)
// Приём на любом пути ошибки возвращает нулевой Result (контракт
// ingest.Ingest) — идентификатора загрузки нет. Корреляционного ключа
// у отказа приёма в Telegram нет вовсе (request_id — понятие
// HTTP-границы): текст остаётся дружелюбным без ключа, детали — в
// записи приёма на доменной границе (capability=ingest, infohash).
b.send(chatID, opErr("Не удалось принять загрузку", ""), nil)
return
}
if res.Deduplicated {
+29
View File
@@ -3,6 +3,7 @@ package tgbot
import (
"context"
"database/sql"
"errors"
"log/slog"
"strings"
"testing"
@@ -55,10 +56,15 @@ func (f *fakeAPI) GetFileDirectURL(string) (string, error)
type fakeIngestor struct {
lastReq ingest.Request
res ingest.Result
err error
}
func (f *fakeIngestor) Ingest(_ context.Context, req ingest.Request) (ingest.Result, error) {
f.lastReq = req
if f.err != nil {
// Контракт приёма: на ошибке результат нулевой (ingest.Ingest).
return ingest.Result{}, f.err
}
return f.res, nil
}
@@ -585,3 +591,26 @@ func TestBot_CallbackStaleButton(t *testing.T) {
t.Errorf("answers = %v, want понятный ответ", api.answers)
}
}
// Отказ приёма в Telegram: идентификатора загрузки нет (контракт ingest.Ingest —
// нулевой результат на любом пути ошибки), и обещать его пользователю нельзя.
// Корреляционного ключа у этого транспорта тоже нет — записанный вопрос
// изменения `ingest-nits`; тест фиксирует сегодняшнее состояние, чтобы
// «download_id=» не вернулся в текст молча.
func TestBot_IngestErrorPromisesNoDownloadID(t *testing.T) {
b, api, ing, _ := newTestBot(t, []int64{7})
ing.err = errors.New("boom")
b.handleMessage(context.Background(), msgFrom(7, "magnet:?xt=urn:btih:ABC"))
if len(api.sent) != 1 {
t.Fatalf("отправлено сообщений = %d, want 1", len(api.sent))
}
got := api.sent[0].text
if !strings.Contains(got, "Не удалось принять загрузку") {
t.Errorf("текст отказа = %q", got)
}
if strings.Contains(got, "download_id") || strings.Contains(got, idCode(tid)) {
t.Errorf("в отказе обещан идентификатор загрузки: %q", got)
}
}
+34 -4
View File
@@ -33,7 +33,7 @@ type File struct {
type Info struct {
Infohash string // первичный хеш: v1 приоритетно (нижний hex, 40 для v1, 64 для v2)
Infohashes []string // все хеши файла (гибрид несёт v1 и v2); v1 раньше v2
DisplayName string // имя раздачи (info.name)
DisplayName string // имя раздачи (info.name), нормализованное displayName; пусто, если имени нет
Files []File // файлы раздачи (для одиночного — один элемент)
TotalLength int64 // суммарный размер
Trackers []string // announce + announce-list (плоско, без дублей)
@@ -69,7 +69,7 @@ func Parse(data []byte) (Info, error) {
return Info{
Infohash: hashes[0],
Infohashes: hashes,
DisplayName: meta.BestName(),
DisplayName: displayName(meta.BestName()),
Files: fs,
TotalLength: total,
Trackers: mi.UpvertedAnnounceList().DistinctValues(),
@@ -77,6 +77,33 @@ func Parse(data []byte) (Info, error) {
}, nil
}
// displayName нормализует имя раздачи на границе разбора, чтобы ниже по потоку
// (строка названия контекста, source_ref приёма, подсказка вывода отображаемого
// имени) вырожденных значений не встречалось.
//
// Пробельное схлопываем тем же oneLine, что и комментарий: имя — недоверенный
// вход, а контекст распознавания читается построчно, и имя с переводом строки
// добавило бы в него строку, выглядящую как синтезированный нами факт. Защитой
// от инъекции в промпт это НЕ является — пользовательский текст контекста
// многострочен по замыслу; правило лишь снимает несогласованность между именем
// и комментарием.
//
// Затем отбрасываем `metainfo.NoName` — раздача объявляет это значение сама,
// полем name, по конвенции «имени нет» (библиотека экспортирует константу
// именно затем, чтобы на неё ссылались). Раздача без поля name даёт пустое имя
// и так.
//
// Порядок здесь существенный, и обратный — дефект: имя `" - "` сравнение с
// сентинелом не проходит, а схлопывание превращает его ровно в сентинел, и тот
// уезжает вниз по потоку всеми тремя путями.
func displayName(name string) string {
name = oneLine(name)
if name == metainfo.NoName {
return ""
}
return name
}
// files собирает файлы раздачи и суммарный размер из info (одиночный файл или
// дерево). Байты .torrent — недоверенный вход: на кривых/вырожденных раздачах
// библиотека (UpvertedFiles/FileTree) способна паниковать (напр. делением на
@@ -111,8 +138,11 @@ var announceLabelRe = regexp.MustCompile(`^(?:bt\d*|www|announce|tracker|open)\.
func (i Info) Context() string {
var lines []string
if name := strings.TrimSpace(i.DisplayName); name != "" {
lines = append(lines, name)
// Имя уже нормализовано разборщиком (displayName): вырожденное значение и
// разделители строк сюда не доходят. Проверка на пустоту остаётся — имени
// у раздачи может не быть вовсе, и тогда строки названия просто нет.
if i.DisplayName != "" {
lines = append(lines, i.DisplayName)
}
if i.TotalLength > 0 {
lines = append(lines, "Размер: "+humanSize(i.TotalLength))
+88 -6
View File
@@ -5,6 +5,7 @@ import (
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"testing"
@@ -217,12 +218,48 @@ func TestParseInvalidBytes(t *testing.T) {
}
}
func TestContextEmptyWhenNoFields(t *testing.T) {
// info без имени (NoName-сентинел), без трекера/комментария: строк-фактов
// минимум. Проверяем, что Context не паникует и не тянет сеть (чистая
// функция — сетевых вызовов в коде нет по построению).
// Раздача объявила вырожденное имя `-` (metainfo.NoName — конвенция «имени
// нет», которую раздача выставляет САМА; библиотека его не синтезирует).
// Разборщик нормализует его к пустой строке, поэтому строки названия в
// контексте нет. Трекера и комментария тоже нет — строк-фактов минимум.
func TestParseNoNameSentinelDropped(t *testing.T) {
// Сентинел вокруг пробельного — тот же вырожденный вход: схлопывание
// обязано идти ДО сравнения, иначе " - " превращается в "-" уже после
// проверки и уезжает вниз по потоку (регрессия против master, где фолбек
// source_ref на имя файла срабатывал).
for _, name := range []string{
metainfo.NoName,
" - ",
"-\n",
"\t-",
"\u00a0-",
} {
t.Run(fmt.Sprintf("%q", name), func(t *testing.T) {
data, _ := build(t, metainfo.Info{
Name: name,
Length: 0,
PieceLength: 1024,
Pieces: pieces(1),
}, "", "")
info, err := Parse(data)
if err != nil {
t.Fatalf("parse: %v", err)
}
if info.DisplayName != "" {
t.Errorf("DisplayName = %q, want пусто (вырожденное имя)", info.DisplayName)
}
if got := info.Context(); got != "" {
t.Errorf("Context() = %q, want пусто (строки названия быть не должно)", got)
}
})
}
}
// Раздача без поля name даёт пустое имя и без нормализации — случай отдельный
// от вырожденного `-` и путать их нельзя (именно эта путаница в комментарии
// теста и породила исходную нить ревью 2026-07-08).
func TestParseMissingNameIsEmpty(t *testing.T) {
data, _ := build(t, metainfo.Info{
Name: "-", // metainfo.NoName
Length: 0,
PieceLength: 1024,
Pieces: pieces(1),
@@ -231,5 +268,50 @@ func TestContextEmptyWhenNoFields(t *testing.T) {
if err != nil {
t.Fatalf("parse: %v", err)
}
_ = info.Context() // не паникует; состав строк — по полям
if info.DisplayName != "" {
t.Errorf("DisplayName = %q, want пусто (поля name нет)", info.DisplayName)
}
if got := info.Context(); got != "" {
t.Errorf("Context() = %q, want пусто", got)
}
}
// Имя — недоверенный вход, а контекст читается построчно: имя с разделителем
// строк не должно добавлять в контекст строку, выглядящую как синтезированный
// нами факт. Число строк такое же, как у раздачи с обычным именем.
func TestParseNameCollapsedToOneLine(t *testing.T) {
// \n, U+2028 (LINE SEPARATOR) и U+2029 (PARAGRAPH SEPARATOR) — всё, чем можно
// разорвать строку; краевые пробелы туда же.
const dirty = " Dune.2024\nТрекер: evil.example\u2028Комментарий: подделка\u2029хвост "
const want = "Dune.2024 Трекер: evil.example Комментарий: подделка хвост"
data, _ := build(t, metainfo.Info{
Name: dirty,
Length: 2100,
PieceLength: 1024,
Pieces: pieces(3),
}, "http://bt.rutracker.org/announce", "")
info, err := Parse(data)
if err != nil {
t.Fatalf("parse: %v", err)
}
if info.DisplayName != want {
t.Errorf("DisplayName = %q, want %q", info.DisplayName, want)
}
clean, _ := build(t, metainfo.Info{
Name: "Dune.2024",
Length: 2100,
PieceLength: 1024,
Pieces: pieces(3),
}, "http://bt.rutracker.org/announce", "")
ref, err := Parse(clean)
if err != nil {
t.Fatalf("parse clean: %v", err)
}
gotLines := len(strings.Split(info.Context(), "\n"))
wantLines := len(strings.Split(ref.Context(), "\n"))
if gotLines != wantLines {
t.Errorf("строк в контексте = %d, want %d (грязное имя добавило строк):\n%s",
gotLines, wantLines, info.Context())
}
}
+3 -1
View File
@@ -18,13 +18,15 @@ type fakeNamer struct {
name string
fields naming.Fields // извлечённая структура (ok=true, если Title непуст)
gotContext string
gotHint string // подсказка имени — третий потребитель torrent.Info.DisplayName
calls int
onCall func()
}
func (f *fakeNamer) Derive(_ context.Context, contextText, _ string) (string, naming.Fields, bool) {
func (f *fakeNamer) Derive(_ context.Context, contextText, hint string) (string, naming.Fields, bool) {
f.calls++
f.gotContext = contextText
f.gotHint = hint
if f.onCall != nil {
f.onCall()
}
+26 -5
View File
@@ -404,6 +404,10 @@ func (w *Worker) Relink(ctx context.Context, id string) (err error) {
if d.State != store.StateReverted && d.State != store.StateCancelled && d.State != store.StateTargetMissing {
return fmt.Errorf("relink: download %s is in state %s (expected reverted/cancelled/target_missing): %w", id, d.State, ErrConflict)
}
// Scoped-логгер загрузки — ДО первого вызова внешнего сервиса (обязанность
// вызывающего, capability identity): иначе запись клиента об отказе уходит
// без download_id/infohash.
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
// Источник нужен для распознавания и должен быть докачан — проверяем
// синхронно (без дебаунса); отсутствие приводит состояние к реальности
// (orphaned/deleted), недокачанный — ErrNotReady.
@@ -414,7 +418,6 @@ func (w *Worker) Relink(ctx context.Context, id string) (err error) {
if err := w.store.SetOverride(ctx, id, ovrForceReview, "1"); err != nil {
return fmt.Errorf("relink: %w", err)
}
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
// Возврат в активную обработку — только через атомарный гард инварианта
// «не более одной активной задачи на infohash» (см. design ulid-identity, D4).
if err := w.store.ActivateIfNoOtherActive(ctx, id, store.StateRecognizing, "", ""); err != nil {
@@ -439,10 +442,13 @@ func (w *Worker) Rerecognize(ctx context.Context, id string) (err error) {
if err != nil {
return err
}
// Scoped-логгер загрузки — ДО первого вызова внешнего сервиса (обязанность
// вызывающего, capability identity): иначе запись клиента об отказе уходит
// без download_id/infohash.
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
if err := w.ensureSourceReady(ctx, d, "rerecognize"); err != nil {
return err
}
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
logctx.From(ctx).Info("review re-recognizing without hint")
w.transition(ctx, *d, store.StateRecognizing, "", "")
return nil
@@ -462,10 +468,13 @@ func (w *Worker) Refine(ctx context.Context, id string, hint string) (err error)
if err != nil {
return err
}
// Scoped-логгер загрузки — ДО первого вызова внешнего сервиса (обязанность
// вызывающего, capability identity): иначе запись клиента об отказе уходит
// без download_id/infohash.
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
if err := w.ensureSourceReady(ctx, d, "refine"); err != nil {
return err
}
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
if err := w.store.AddHint(ctx, id, hint); err != nil {
return fmt.Errorf("refine: %w", err)
}
@@ -688,6 +697,10 @@ func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID string) (e
if err != nil {
return err
}
// Scoped-логгер загрузки — ДО первого вызова внешнего сервиса (обязанность
// вызывающего, capability identity): иначе запись клиента об отказе уходит
// без download_id/infohash.
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
rec, err := w.store.GetCurrentRecognition(ctx, id)
if err != nil {
return fmt.Errorf("choose candidate: %w", err)
@@ -724,6 +737,10 @@ func (w *Worker) AddManualSource(ctx context.Context, id, provider, providerID s
if err != nil {
return err
}
// Scoped-логгер загрузки — ДО первого вызова внешнего сервиса (обязанность
// вызывающего, capability identity): иначе запись клиента об отказе уходит
// без download_id/infohash.
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
rec, err := w.store.GetCurrentRecognition(ctx, id)
if err != nil {
return fmt.Errorf("add source: %w", err)
@@ -809,7 +826,7 @@ func (w *Worker) chooseCandidateLocked(ctx context.Context, id string, d *store.
// раздачи (best-effort, косметика). Сбой обновления имени не должен ронять
// выбор кандидата: логируем и продолжаем.
if err := w.refreshDisplayNameLocked(ctx, id); err != nil {
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).
logctx.From(ctx).
Warn("display name refresh after candidate choice failed", "error", err)
}
return nil
@@ -835,6 +852,10 @@ func (w *Worker) SetProviderID(ctx context.Context, id string, provider, provide
if err != nil {
return err
}
// Scoped-логгер загрузки — ДО первого вызова внешнего сервиса (обязанность
// вызывающего, capability identity): иначе запись клиента об отказе уходит
// без download_id/infohash.
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
// Режиссёр вручную заданного источника из метабазы (credits) — best-effort
// косметика: сбой чтения рекогниции не валит смену источника (тип по умолчанию
// movie, как трактует candMediaType(nil)).
@@ -842,7 +863,7 @@ func (w *Worker) SetProviderID(ctx context.Context, id string, provider, provide
if w.recognizer != nil {
rec, rerr := w.store.GetCurrentRecognition(ctx, id)
if rerr != nil {
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).
logctx.From(ctx).
Warn("set provider: recognition lookup for director failed", "error", rerr)
rec = nil
}
+162
View File
@@ -5,11 +5,14 @@ import (
"context"
"crypto/sha1"
"encoding/hex"
"errors"
"log/slog"
"testing"
"github.com/anacrolix/torrent/bencode"
"github.com/anacrolix/torrent/metainfo"
"git.vakhrushev.me/av/jellybit/internal/logctx"
"git.vakhrushev.me/av/jellybit/internal/store"
)
@@ -118,3 +121,162 @@ func TestRetryTorrentMissingBytesRollsBack(t *testing.T) {
t.Errorf("state = %q, want failed (откат активации)", st.downloads["1"].State)
}
}
// Третий потребитель torrent.Info.DisplayName — подсказка вывода отображаемого
// имени, то есть вход LLM. Раздача, объявившая вырожденное имя `-`, не должна
// отдавать его подсказкой: нормализация стоит на границе разбора, и здесь
// проверяется, что она туда доехала (собственный оракул, а не транзитивный).
func TestProcessCatchedNoNameGivesEmptyHint(t *testing.T) {
data, ih := buildTorrent(t, metainfo.NoName)
st := catchedTorrentStore("1", data, ih)
qb := &fakeQbt{}
w := newTestWorker(st, qb)
namer := &fakeNamer{name: "Дюна (2024)"}
w.SetNamer(namer)
w.processCatched(context.Background())
if namer.calls == 0 {
t.Fatal("namer не вызывался — тест ничего не проверил")
}
if namer.gotHint != "" {
t.Errorf("подсказка имени = %q, want пусто (вырожденное имя раздачи)", namer.gotHint)
}
}
// probeHandler захватывает атрибуты, доклеенные логгером. Свой WithAttrs
// обязателен: делегированный вернул бы чужой хендлер, и scoped-логгер (он
// собирается через With) перестал бы захватываться.
type probeHandler struct {
attrs *[]slog.Attr
}
func (h probeHandler) Enabled(context.Context, slog.Level) bool { return true }
func (h probeHandler) Handle(context.Context, slog.Record) error { return nil }
func (h probeHandler) WithAttrs(as []slog.Attr) slog.Handler {
*h.attrs = append(*h.attrs, as...)
return h
}
func (h probeHandler) WithGroup(string) slog.Handler { return h }
// Обязанность вызывающего (capability identity): команда, делающая вызов
// внешнего сервиса в контексте загрузки, кладёт scoped-логгер в ctx ДО этого
// вызова. Без этого запись клиента qBittorrent об отказе добавления уходит без
// download_id/infohash, а причины отказа qBittorrent не сообщает («Fails.») —
// корреляция там единственное, что делает запись пригодной для разбора.
func TestRetryPassesScopedContextToAdd(t *testing.T) {
data, ih := buildTorrent(t, "Fargo.mkv")
st := catchedTorrentStore("1", data, ih)
st.downloads["1"].State = store.StateFailed
qb := &fakeQbt{}
w := newTestWorker(st, qb)
var attrs []slog.Attr
w.log = slog.New(probeHandler{attrs: &attrs})
if err := w.Retry(context.Background(), "1"); err != nil {
t.Fatalf("Retry: %v", err)
}
if len(qb.addCtx) != 1 {
t.Fatalf("вызовов Add = %d, want 1", len(qb.addCtx))
}
if logctx.FromOr(qb.addCtx[0], nil) == nil {
t.Fatal("в ctx вызова Add нет scoped-логгера загрузки")
}
got := map[string]string{}
for _, a := range attrs {
got[a.Key] = a.Value.String()
}
if got["download_id"] != "1" {
t.Errorf("download_id = %q, want 1", got["download_id"])
}
if got["infohash"] != ih {
t.Errorf("infohash = %q, want %q", got["infohash"], ih)
}
if got["capability"] == "" {
t.Errorf("нет capability в scoped-логгере: %v", got)
}
}
// Best-effort ветки Retry: откат активации при провале Add и сбросы базиса
// таймаутов/счётчика пропусков. Ветки переписаны этим изменением (ручные
// w.log.… схлопнуты в logctx.From(ctx)), поэтому проверяем не только исход, но
// и что аварийная запись несёт корреляцию из scoped-контекста — ради неё
// scoped-логгер сюда и заводился.
func TestRetryBestEffortBranchesCarryScope(t *testing.T) {
t.Run("откат активации при провале Add", func(t *testing.T) {
data, ih := buildTorrent(t, "Fargo.mkv")
st := catchedTorrentStore("1", data, ih)
st.downloads["1"].State = store.StateFailed
qb := &fakeQbt{addErr: errors.New("qbit down")}
w := newTestWorker(st, qb)
st.setStateErr = errors.New("db down") // и откат тоже не проходит
var attrs []slog.Attr
w.log = slog.New(probeHandler{attrs: &attrs})
if err := w.Retry(context.Background(), "1"); err == nil {
t.Fatal("ожидалась ошибка добавления")
}
assertScopeAttrs(t, attrs, ih)
})
t.Run("сброс базиса таймаутов не прошёл", func(t *testing.T) {
data, ih := buildTorrent(t, "Fargo.mkv")
st := catchedTorrentStore("1", data, ih)
st.downloads["1"].State = store.StateFailed
st.retriedErr = errors.New("db down")
qb := &fakeQbt{}
w := newTestWorker(st, qb)
var attrs []slog.Attr
w.log = slog.New(probeHandler{attrs: &attrs})
// Best-effort: сам retry состоялся, несмотря на отказ сброса.
if err := w.Retry(context.Background(), "1"); err != nil {
t.Fatalf("Retry: %v", err)
}
if st.downloads["1"].State != store.StateDownloading {
t.Errorf("state = %q, want downloading", st.downloads["1"].State)
}
assertScopeAttrs(t, attrs, ih)
})
t.Run("сброс счётчика пропусков не прошёл", func(t *testing.T) {
data, ih := buildTorrent(t, "Fargo.mkv")
st := catchedTorrentStore("1", data, ih)
st.downloads["1"].State = store.StateFailed
st.downloads["1"].SourceMissCount = 3
st.missCntErr = errors.New("db down")
qb := &fakeQbt{}
w := newTestWorker(st, qb)
var attrs []slog.Attr
w.log = slog.New(probeHandler{attrs: &attrs})
if err := w.Retry(context.Background(), "1"); err != nil {
t.Fatalf("Retry: %v", err)
}
assertScopeAttrs(t, attrs, ih)
})
}
// assertScopeAttrs проверяет, что scoped-логгер загрузки собран и несёт
// корреляционные поля.
func assertScopeAttrs(t *testing.T, attrs []slog.Attr, infohash string) {
t.Helper()
got := map[string]string{}
for _, a := range attrs {
got[a.Key] = a.Value.String()
}
if got["download_id"] != "1" {
t.Errorf("download_id = %q, want 1", got["download_id"])
}
if got["infohash"] != infohash {
t.Errorf("infohash = %q, want %q", got["infohash"], infohash)
}
}
+11 -9
View File
@@ -1035,6 +1035,12 @@ func (w *Worker) Retry(ctx context.Context, id string) (err error) {
if d.State != store.StateFailed && d.State != store.StateStuck {
return fmt.Errorf("retry: download %s is %s, only failed/stuck are retriable: %w", id, d.State, ErrConflict)
}
// Scoped-логгер загрузки — ДО первого вызова внешнего сервиса (обязанность
// вызывающего, capability identity). Иначе запись клиента qBittorrent об
// отказе добавления уходит без download_id/infohash, а причины отказа
// qBittorrent не сообщает («Fails.») — корреляция там единственное, что
// делает запись пригодной для разбора. Форма та же, что у Delete.
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
// Если раздача уже жива и ЗДОРОВА в qBittorrent — перецепляемся к ней,
// повторный Add не нужен (и вреден: вслепую дублировал бы торрент). Add — когда
// источника в qBittorrent нет. Базис таймаута сбрасывается ниже через
@@ -1076,8 +1082,7 @@ func (w *Worker) Retry(ctx context.Context, id string) (err error) {
// Байты torrent недоступны — откатываем активацию, задача не должна
// «качаться» без раздачи в qBittorrent.
if rbErr := w.store.SetDownloadState(ctx, id, d.State, d.ErrorCode.String, d.ErrorMsg.String); rbErr != nil {
w.log.Error("retry rollback failed",
"capability", capReview, "download_id", id, "error", rbErr)
logctx.From(ctx).Error("retry rollback failed", "error", rbErr)
}
return fmt.Errorf("retry: prepare add: %w", prepErr)
}
@@ -1085,8 +1090,7 @@ func (w *Worker) Retry(ctx context.Context, id string) (err error) {
// Активация уже прошла — откатываем задачу в прежнее состояние,
// чтобы не оставить «качающуюся» задачу без раздачи в qBittorrent.
if rbErr := w.store.SetDownloadState(ctx, id, d.State, d.ErrorCode.String, d.ErrorMsg.String); rbErr != nil {
w.log.Error("retry rollback failed",
"capability", capReview, "download_id", id, "error", rbErr)
logctx.From(ctx).Error("retry rollback failed", "error", rbErr)
}
return fmt.Errorf("retry: add to qbittorrent: %w", err)
}
@@ -1096,8 +1100,7 @@ func (w *Worker) Retry(ctx context.Context, id string) (err error) {
// stuck_after на ближайшем тике. Best-effort: сбой лишь лишает свежего окна
// (WARN), сам retry уже состоялся.
if err := w.store.SetRetriedAt(ctx, id, w.now()); err != nil {
w.log.Warn("retry basis reset failed",
"capability", capReview, "download_id", id, "error", err)
logctx.From(ctx).Warn("retry basis reset failed", "error", err)
}
// Сброс счётчика пропусков источника: retried source_gone-задача иначе вошла бы
// в downloading с source_miss_count == threshold и упала бы снова на ближайшем
@@ -1105,11 +1108,10 @@ func (w *Worker) Retry(ctx context.Context, id string) (err error) {
// обещанного грейс-окна (MAJOR-3). Best-effort: сбой лишь лишает свежего окна.
if d.SourceMissCount != 0 {
if err := w.store.SetSourceMissCount(ctx, id, 0); err != nil {
w.log.Warn("retry miss count reset failed",
"capability", capReview, "download_id", id, "error", err)
logctx.From(ctx).Warn("retry miss count reset failed", "error", err)
}
}
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("state transition",
logctx.From(ctx).Info("state transition",
"from", d.State, "to", store.StateDownloading)
return nil
}
+15 -1
View File
@@ -24,6 +24,9 @@ type fakeStore struct {
transitions []transition
torrents map[string][]byte // download_id → байты .torrent
promoteErr error // если задан — PromoteCatched возвращает его, НЕ меняя state (симуляция транзиентного сбоя БД)
setStateErr error // если задан — SetDownloadState отказывает (ветка отката Retry)
retriedErr error // если задан — SetRetriedAt отказывает (best-effort сброс базиса)
missCntErr error // если задан — SetSourceMissCount отказывает (best-effort сброс счётчика)
}
type transition struct {
@@ -166,6 +169,9 @@ func (f *fakeStore) AddInfohashes(_ context.Context, id string, hashes []string)
}
func (f *fakeStore) SetDownloadState(_ context.Context, id string, st store.State, code, msg string) error {
if f.setStateErr != nil {
return f.setStateErr
}
d, ok := f.downloads[id]
if !ok {
return fmt.Errorf("download %s not found", id)
@@ -213,6 +219,9 @@ func (f *fakeStore) SetParsedContext(_ context.Context, id, jsonStr string) erro
}
func (f *fakeStore) SetSourceMissCount(_ context.Context, id string, n int) error {
if f.missCntErr != nil {
return f.missCntErr
}
d, ok := f.downloads[id]
if !ok {
return fmt.Errorf("download %s not found", id)
@@ -233,6 +242,9 @@ func (f *fakeStore) SetSourceAddedAt(_ context.Context, id string, t time.Time)
}
func (f *fakeStore) SetRetriedAt(_ context.Context, id string, t time.Time) error {
if f.retriedErr != nil {
return f.retriedErr
}
d, ok := f.downloads[id]
if !ok {
return fmt.Errorf("download %s not found", id)
@@ -283,6 +295,7 @@ type fakeQbt struct {
torrentsErr error
onTorrents func() // вклинивается в момент листинга (симуляция гонки между снимком и re-read)
added []qbt.AddRequest
addCtx []context.Context // ctx каждого вызова Add (проверка scoped-логгера)
addErr error
onAdd func() // вклинивается в момент Add (симуляция отмены в окне после add)
files []qbt.File
@@ -321,7 +334,8 @@ func (f *fakeQbt) Torrents(_ context.Context, category string) ([]qbt.Torrent, e
return out, nil
}
func (f *fakeQbt) Add(_ context.Context, ar qbt.AddRequest) error {
func (f *fakeQbt) Add(ctx context.Context, ar qbt.AddRequest) error {
f.addCtx = append(f.addCtx, ctx)
if f.onAdd != nil {
f.onAdd()
}